diff options
Diffstat (limited to 'activerecord/test/cases')
325 files changed, 76310 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..66e594d771 --- /dev/null +++ b/activerecord/test/cases/adapter_test.rb @@ -0,0 +1,513 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" +require "models/book" +require "models/post" +require "models/author" +require "models/event" + +module ActiveRecord + class AdapterTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + @connection.materialize_transactions + end + + ## + # PostgreSQL does not support null bytes in strings + unless current_adapter?(:PostgreSQLAdapter) || + (current_adapter?(:SQLite3Adapter) && !ActiveRecord::Base.connection.prepared_statements) + def test_update_prepared_statement + b = Book.create(name: "my \x00 book") + b.reload + assert_equal "my \x00 book", b.name + b.update(name: "my other \x00 book") + b.reload + assert_equal "my other \x00 book", b.name + end + end + + def test_create_record_with_pk_as_zero + Book.create(id: 0) + assert_equal 0, Book.find(0).id + assert_nothing_raised { Book.destroy(0) } + end + + def test_valid_column + @connection.native_database_types.each_key do |type| + assert @connection.valid_type?(type) + end + end + + def test_invalid_column + assert_not @connection.valid_type?(:foobar) + end + + def test_tables + tables = @connection.tables + assert_includes tables, "accounts" + assert_includes tables, "authors" + assert_includes tables, "tasks" + assert_includes tables, "topics" + end + + def test_table_exists? + assert @connection.table_exists?("accounts") + assert @connection.table_exists?(:accounts) + assert_not @connection.table_exists?("nonexistingtable") + assert_not @connection.table_exists?("'") + assert_not @connection.table_exists?(nil) + end + + def test_data_sources + data_sources = @connection.data_sources + assert_includes data_sources, "accounts" + assert_includes data_sources, "authors" + assert_includes data_sources, "tasks" + assert_includes data_sources, "topics" + end + + def test_data_source_exists? + assert @connection.data_source_exists?("accounts") + assert @connection.data_source_exists?(:accounts) + assert_not @connection.data_source_exists?("nonexistingtable") + assert_not @connection.data_source_exists?("'") + assert_not @connection.data_source_exists?(nil) + end + + def test_indexes + idx_name = "accounts_idx" + + indexes = @connection.indexes("accounts") + assert_empty indexes + + @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_not indexes.first.unique + assert_equal ["firm_id"], indexes.first.columns + ensure + @connection.remove_index(:accounts, name: idx_name) rescue nil + end + + def test_remove_index_when_name_and_wrong_column_name_specified + index_name = "accounts_idx" + + @connection.add_index :accounts, :firm_id, name: index_name + assert_raises ArgumentError do + @connection.remove_index :accounts, name: index_name, column: :wrong_column_name + end + ensure + @connection.remove_index(:accounts, name: index_name) + end + + def test_current_database + if @connection.respond_to?(:current_database) + assert_equal ARTest.connection_config["arunit"]["database"], @connection.current_database + end + end + + def test_exec_query_returns_an_empty_result + result = @connection.exec_query "INSERT INTO subscribers(nick) VALUES('me')" + assert_instance_of(ActiveRecord::Result, result) + end + + if current_adapter?(:Mysql2Adapter) + 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 + 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 + + 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 + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + assert_no_queries do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.transaction do + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')", nil, false) + end + end + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") + + assert_no_queries do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.transaction do + @connection.update("UPDATE subscribers SET nick = '9989' WHERE nick = '138853948594'") + end + end + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") + + assert_no_queries do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.transaction do + @connection.delete("DELETE FROM subscribers WHERE nick = '138853948594'") + end + end + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + @connection.insert("INSERT INTO subscribers(nick) VALUES ('138853948594')") + + @connection.while_preventing_writes do + result = @connection.select_all("SELECT subscribers.* FROM subscribers WHERE nick = '138853948594'") + assert_equal 1, result.length + end + end + + def test_uniqueness_violations_are_translated_to_specific_exception + @connection.execute "INSERT INTO subscribers(nick) VALUES('me')" + error = assert_raises(ActiveRecord::RecordNotUnique) do + @connection.execute "INSERT INTO subscribers(nick) VALUES('me')" + end + + assert_not_nil error.cause + end + + def test_not_null_violations_are_translated_to_specific_exception + error = assert_raises(ActiveRecord::NotNullViolation) do + Post.create + end + + assert_not_nil error.cause + end + + unless current_adapter?(:SQLite3Adapter) + def test_value_limit_violations_are_translated_to_specific_exception + error = assert_raises(ActiveRecord::ValueTooLong) do + Event.create(title: "abcdefgh") + end + + assert_not_nil error.cause + end + + def test_numeric_value_out_of_ranges_are_translated_to_specific_exception + error = assert_raises(ActiveRecord::RangeError) do + Book.connection.create("INSERT INTO books(author_id) VALUES (9223372036854775808)") + end + + assert_not_nil error.cause + end + end + + def test_exceptions_from_notifications_are_not_translated + original_error = StandardError.new("This StandardError shouldn't get translated") + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") { raise original_error } + actual_error = assert_raises(StandardError) do + @connection.execute("SELECT * FROM posts") + end + + assert_equal original_error, actual_error + + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_database_related_exceptions_are_translated_to_statement_invalid + error = assert_raises(ActiveRecord::StatementInvalid) do + @connection.execute("This is a syntax error") + end + + assert_instance_of ActiveRecord::StatementInvalid, error + assert_kind_of Exception, error.cause + end + + def test_select_all_always_return_activerecord_result + result = @connection.select_all "SELECT * FROM posts" + assert result.is_a?(ActiveRecord::Result) + end + + if ActiveRecord::Base.connection.prepared_statements + def test_select_all_with_legacy_binds + post = Post.create!(title: "foo", body: "bar") + expected = @connection.select_all("SELECT * FROM posts WHERE id = #{post.id}") + result = @connection.select_all("SELECT * FROM posts WHERE id = #{Arel::Nodes::BindParam.new(nil).to_sql}", nil, [[nil, post.id]]) + assert_equal expected.to_a, result.to_a + end + + def test_insert_update_delete_with_legacy_binds + binds = [[nil, 1]] + bind_param = Arel::Nodes::BindParam.new(nil) + + id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds) + assert_equal 1, id + + @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_equal({ "id" => 1, "title" => "foo" }, result.first) + + @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_nil result.first + end + + def test_insert_update_delete_with_binds + binds = [Relation::QueryAttribute.new("id", 1, Type.default_value)] + bind_param = Arel::Nodes::BindParam.new(nil) + + id = @connection.insert("INSERT INTO events(id) VALUES (#{bind_param.to_sql})", nil, nil, nil, nil, binds) + assert_equal 1, id + + @connection.update("UPDATE events SET title = 'foo' WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_equal({ "id" => 1, "title" => "foo" }, result.first) + + @connection.delete("DELETE FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + result = @connection.select_all("SELECT * FROM events WHERE id = #{bind_param.to_sql}", nil, binds) + assert_nil result.first + end + 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)) + 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)) + 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 + + def test_supports_multi_insert_is_deprecated + assert_deprecated { @connection.supports_multi_insert? } + end + + def test_column_name_length_is_deprecated + assert_deprecated { @connection.column_name_length } + end + + def test_table_name_length_is_deprecated + assert_deprecated { @connection.table_name_length } + end + + def test_columns_per_table_is_deprecated + assert_deprecated { @connection.columns_per_table } + end + + def test_indexes_per_table_is_deprecated + assert_deprecated { @connection.indexes_per_table } + end + + def test_columns_per_multicolumn_index_is_deprecated + assert_deprecated { @connection.columns_per_multicolumn_index } + end + + def test_sql_query_length_is_deprecated + assert_deprecated { @connection.sql_query_length } + end + + def test_joins_per_query_is_deprecated + assert_deprecated { @connection.joins_per_query } + end + end + + class AdapterForeignKeyTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :fk_test_has_pk + + def setup + @connection = ActiveRecord::Base.connection + 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 + + error = assert_raises(ActiveRecord::InvalidForeignKey) do + has_fk = klass_has_fk.new + has_fk.fk_id = 1231231231 + has_fk.save(validate: false) + end + + assert_not_nil error.cause + end + + def test_foreign_key_violations_on_insert_are_translated_to_specific_exception + error = assert_raises(ActiveRecord::InvalidForeignKey) do + insert_into_fk_test_has_fk + end + + assert_not_nil error.cause + end + + def test_foreign_key_violations_on_delete_are_translated_to_specific_exception + insert_into_fk_test_has_fk fk_id: 1 + + error = assert_raises(ActiveRecord::InvalidForeignKey) do + @connection.execute "DELETE FROM fk_test_has_pk WHERE pk_id = 1" + end + + assert_not_nil error.cause + end + + def test_disable_referential_integrity + assert_nothing_raised do + @connection.disable_referential_integrity do + insert_into_fk_test_has_fk + # 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 + + private + def insert_into_fk_test_has_fk(fk_id: 0) + # 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},#{fk_id})" + else + @connection.execute "INSERT INTO fk_test_has_fk (fk_id) VALUES (#{fk_id})" + end + end + end + + class AdapterTestWithoutTransaction < ActiveRecord::TestCase + self.use_transactional_tests = 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_predicate @connection, :transaction_open? + @connection.reconnect! + assert_not_predicate @connection, :transaction_open? + end + + test "transaction state is reset after a disconnect" do + @connection.begin_transaction + assert_predicate @connection, :transaction_open? + @connection.disconnect! + assert_not_predicate @connection, :transaction_open? + 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 + end +end + +if ActiveRecord::Base.connection.supports_advisory_locks? + class AdvisoryLocksEnabledTest < ActiveRecord::TestCase + include ConnectionHelper + + def test_advisory_locks_enabled? + assert ActiveRecord::Base.connection.advisory_locks_enabled? + + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection( + orig_connection.merge(advisory_locks: false) + ) + + assert_not ActiveRecord::Base.connection.advisory_locks_enabled? + + ActiveRecord::Base.establish_connection( + orig_connection.merge(advisory_locks: true) + ) + + assert ActiveRecord::Base.connection.advisory_locks_enabled? + 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..2d71ee2f15 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/active_schema_test.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class Mysql2ActiveSchemaTest < ActiveRecord::Mysql2TestCase + include ConnectionHelper + + def setup + ActiveRecord::Base.connection.send(:default_row_format) + ActiveRecord::Base.connection.singleton_class.class_eval do + alias_method :execute_without_stub, :execute + def execute(sql, name = nil) sql end + end + end + + teardown do + reset_connection + end + + def test_add_index + # add_index calls data_source_exists? and index_name_exists? which can't work since execute is stubbed + def (ActiveRecord::Base.connection).data_source_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) + 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 }) + 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 }) + 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_index_in_create + def (ActiveRecord::Base.connection).data_source_exists?(*); false; end + + %w(SPATIAL FULLTEXT UNIQUE).each do |type| + expected = /\ACREATE TABLE `people` \(#{type} INDEX `index_people_on_last_name` \(`last_name`\)\)/ + actual = ActiveRecord::Base.connection.create_table(:people, id: false) do |t| + t.index :last_name, type: type + end + assert_match expected, actual + end + + expected = /\ACREATE TABLE `people` \( INDEX `index_people_on_last_name` USING btree \(`last_name`\(10\)\)\)/ + actual = ActiveRecord::Base.connection.create_table(:people, id: false) do |t| + t.index :last_name, length: 10, using: :btree + end + assert_match expected, actual + end + + def test_index_in_bulk_change + def (ActiveRecord::Base.connection).data_source_exists?(*); true; end + def (ActiveRecord::Base.connection).index_name_exists?(*); false; end + + %w(SPATIAL FULLTEXT UNIQUE).each do |type| + expected = "ALTER TABLE `people` ADD #{type} INDEX `index_people_on_last_name` (`last_name`)" + actual = ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t| + t.index :last_name, type: type + end + assert_equal expected, actual + end + + expected = "ALTER TABLE `people` ADD INDEX `index_people_on_last_name` USING btree (`last_name`(10)), ALGORITHM = COPY" + actual = ActiveRecord::Base.connection.change_table(:people, bulk: true) do |t| + t.index :last_name, length: 10, using: :btree, algorithm: :copy + end + assert_equal expected, actual + end + + def test_drop_table + assert_equal "DROP TABLE `people`", drop_table(:people) + end + + def test_create_mysql_database_with_encoding + if row_format_dynamic_by_default? + assert_equal "CREATE DATABASE `matt` DEFAULT CHARACTER SET `utf8mb4`", create_database(:matt) + else + error = assert_raises(RuntimeError) { create_database(:matt) } + expected = "Configure a supported :charset and ensure innodb_large_prefix is enabled to support indexes on varchar(255) string columns." + assert_equal expected, error.message + end + assert_equal "CREATE DATABASE `aimonetti` DEFAULT CHARACTER SET `latin1`", create_database(:aimonetti, charset: "latin1") + assert_equal "CREATE DATABASE `matt_aimonetti` DEFAULT COLLATE `utf8mb4_bin`", create_database(:matt_aimonetti, collation: "utf8mb4_bin") + 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 + + 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 + ActiveRecord::Base.connection.create_table :delete_me + ActiveRecord::Base.connection.add_timestamps :delete_me, null: true + 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 + + def test_remove_timestamps + with_real_execute do + ActiveRecord::Base.connection.create_table :delete_me do |t| + t.timestamps null: true + end + ActiveRecord::Base.connection.remove_timestamps :delete_me, null: true + assert_not column_present?("delete_me", "updated_at", "datetime") + assert_not column_present?("delete_me", "created_at", "datetime") + ensure + ActiveRecord::Base.connection.drop_table :delete_me rescue nil + end + end + + def test_indexes_in_create + assert_called_with( + ActiveRecord::Base.connection, + :data_source_exists?, + [:temp], + returns: false + ) do + expected = /\ACREATE TEMPORARY TABLE `temp` \( INDEX `index_temp_on_zip` \(`zip`\)\)(?: ROW_FORMAT=DYNAMIC)? 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_match expected, actual + end + 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/auto_increment_test.rb b/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb new file mode 100644 index 0000000000..4c67633946 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/auto_increment_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class Mysql2AutoIncrementTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table :auto_increments, if_exists: true + end + + def test_auto_increment_without_primary_key + @connection.create_table :auto_increments, id: false, force: true do |t| + t.integer :id, null: false, auto_increment: true + t.index :id + end + output = dump_table_schema("auto_increments") + assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output) + end + + def test_auto_increment_with_composite_primary_key + @connection.create_table :auto_increments, primary_key: [:id, :created_at], force: true do |t| + t.integer :id, null: false, auto_increment: true + t.datetime :created_at, null: false + end + output = dump_table_schema("auto_increments") + assert_match(/t\.integer\s+"id",\s+null: false,\s+auto_increment: true$/, output) + 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..825bddfb73 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/bind_parameter_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +module ActiveRecord + module ConnectionAdapters + class Mysql2Adapter + class BindParameterTest < ActiveRecord::Mysql2TestCase + 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..db09b30361 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/boolean_test.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Mysql2BooleanTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + + class BooleanType < ActiveRecord::Base + self.table_name = "mysql_booleans" + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.clear_cache! + @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 "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"] + + boolean = BooleanType.create!(archived: false, published: false) + attributes = boolean.reload.attributes_before_type_cast + assert_equal 0, attributes["archived"] + assert_equal "0", attributes["published"] + + assert_equal 1, @connection.type_cast(true) + assert_equal 0, @connection.type_cast(false) + end + + 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"] + + boolean = BooleanType.create!(archived: false, published: false) + attributes = boolean.reload.attributes_before_type_cast + assert_equal 0, attributes["archived"] + assert_equal "0", attributes["published"] + + assert_equal 1, @connection.type_cast(true) + assert_equal 0, @connection.type_cast(false) + 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..c32475c683 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/case_sensitivity_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Mysql2CaseSensitivityTest < ActiveRecord::Mysql2TestCase + class CollationTest < ActiveRecord::Base + end + + repair_validations(CollationTest) + + def test_columns_include_collation_different_from_table + assert_equal "utf8mb4_bin", CollationTest.columns_hash["string_cs_column"].collation + assert_equal "utf8mb4_general_ci", CollationTest.columns_hash["string_ci_column"].collation + end + + def test_case_sensitive + assert_not_predicate CollationTest.columns_hash["string_ci_column"], :case_sensitive? + assert_predicate 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 + + def test_case_sensitive_comparison_for_binary_column + CollationTest.validates_uniqueness_of(:binary_column, case_sensitive: true) + CollationTest.create!(binary_column: "A") + invalid = CollationTest.new(binary_column: "A") + queries = assert_sql { invalid.save } + bin_uniqueness_query = queries.detect { |q| q.match(/binary_column/) } + assert_no_match(/\bBINARY\b/, bin_uniqueness_query) + end +end diff --git a/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb new file mode 100644 index 0000000000..0bdbefdfb9 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/charset_collation_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class Mysql2CharsetCollationTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :charset_collations, force: true do |t| + t.string :string_ascii_bin, charset: "ascii", collation: "ascii_bin" + t.text :text_ucs2_unicode_ci, charset: "ucs2", collation: "ucs2_unicode_ci" + end + end + + teardown do + @connection.drop_table :charset_collations, if_exists: true + end + + test "string column with charset and collation" do + column = @connection.columns(:charset_collations).find { |c| c.name == "string_ascii_bin" } + assert_equal :string, column.type + assert_equal "ascii_bin", column.collation + end + + test "text column with charset and collation" do + column = @connection.columns(:charset_collations).find { |c| c.name == "text_ucs2_unicode_ci" } + assert_equal :text, column.type + assert_equal "ucs2_unicode_ci", column.collation + end + + test "add column with charset and collation" do + @connection.add_column :charset_collations, :title, :string, charset: "utf8mb4", collation: "utf8mb4_bin" + + column = @connection.columns(:charset_collations).find { |c| c.name == "title" } + assert_equal :string, column.type + assert_equal "utf8mb4_bin", column.collation + end + + test "change column with charset and collation" do + @connection.add_column :charset_collations, :description, :string, charset: "utf8mb4", collation: "utf8mb4_unicode_ci" + @connection.change_column :charset_collations, :description, :text, charset: "utf8mb4", collation: "utf8mb4_general_ci" + + column = @connection.columns(:charset_collations).find { |c| c.name == "description" } + assert_equal :text, column.type + assert_equal "utf8mb4_general_ci", column.collation + end + + test "schema dump includes collation" do + output = dump_table_schema("charset_collations") + assert_match %r{t\.string\s+"string_ascii_bin",\s+collation: "ascii_bin"$}, output + assert_match %r{t\.text\s+"text_ucs2_unicode_ci",\s+collation: "ucs2_unicode_ci"$}, output + 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..3103589186 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/connection_test.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class Mysql2ConnectionTest < ActiveRecord::Mysql2TestCase + include ConnectionHelper + + fixtures :comments + + 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.drop_table "ex", if_exists: true + end + end + + def test_truncate + rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments") + count = rows.first.values.first + assert_operator count, :>, 0 + + ActiveRecord::Base.connection.truncate("comments") + rows = ActiveRecord::Base.connection.exec_query("select count(*) from comments") + count = rows.first.values.first + assert_equal 0, count + end + + def test_no_automatic_reconnection_after_timeout + assert_predicate @connection, :active? + @connection.update("set @@wait_timeout=1") + sleep 2 + assert_not_predicate @connection, :active? + ensure + # Repair all fixture connections so other tests won't break. + @fixture_connections.each(&:verify!) + end + + def test_successful_reconnection_after_timeout_with_manual_reconnect + assert_predicate @connection, :active? + @connection.update("set @@wait_timeout=1") + sleep 2 + @connection.reconnect! + assert_predicate @connection, :active? + end + + def test_successful_reconnection_after_timeout_with_verify + assert_predicate @connection, :active? + @connection.update("set @@wait_timeout=1") + sleep 2 + @connection.verify! + assert_predicate @connection, :active? + end + + def test_execute_after_disconnect + @connection.disconnect! + + error = assert_raise(ActiveRecord::StatementInvalid) do + @connection.execute("SELECT 1") + end + assert_kind_of Mysql2::Error, error.cause + end + + def test_quote_after_disconnect + @connection.disconnect! + + assert_raise(Mysql2::Error) do + @connection.quote("string") + end + end + + def test_active_after_disconnect + @connection.disconnect! + assert_equal false, @connection.active? + end + + def test_wait_timeout_as_string + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge(wait_timeout: "60")) + result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.wait_timeout") + assert_equal 60, result + end + end + + def test_wait_timeout_as_url + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge("url" => "mysql2:///?wait_timeout=60")) + result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.wait_timeout") + assert_equal 60, result + end + end + + def test_mysql_connection_collation_is_configured + assert_equal "utf8mb4_unicode_ci", @connection.show_variable("collation_connection") + assert_equal "utf8mb4_general_ci", ARUnit2Model.connection.show_variable("collation_connection") + end + + def test_mysql_default_in_strict_mode + result = @connection.select_value("SELECT @@SESSION.sql_mode") + assert_match %r(STRICT_ALL_TABLES), result + 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.select_value("SELECT @@SESSION.sql_mode") + assert_no_match %r(STRICT_ALL_TABLES), result + end + end + + def test_mysql_strict_mode_specified_default + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge(strict: :default)) + global_sql_mode = ActiveRecord::Base.connection.select_value("SELECT @@GLOBAL.sql_mode") + session_sql_mode = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.sql_mode") + assert_equal global_sql_mode, session_sql_mode + end + end + + def test_mysql_sql_mode_variable_overrides_strict_mode + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { "sql_mode" => "ansi" })) + result = ActiveRecord::Base.connection.select_value("SELECT @@SESSION.sql_mode") + assert_no_match %r(STRICT_ALL_TABLES), result + end + end + + def test_passing_arbitrary_flags_to_adapter + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge(flags: Mysql2::Client::COMPRESS)) + assert_equal (Mysql2::Client::COMPRESS | Mysql2::Client::FOUND_ROWS), ActiveRecord::Base.connection.raw_connection.query_options[:flags] + end + end + + def test_passing_flags_by_array_to_adapter + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.merge(flags: ["COMPRESS"])) + assert_equal ["COMPRESS", "FOUND_ROWS"], ActiveRecord::Base.connection.raw_connection.query_options[:flags] + 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_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 + ActiveRecord::Base.connection.materialize_transactions + @subscriber.logged.clear + @connection.show_variable "foo" + assert_equal "SCHEMA", @subscriber.logged[0][1] + end + + def test_logs_name_rename_column_for_alter + @connection.execute "CREATE TABLE `bar_baz` (`foo` varchar(255))" + @subscriber.logged.clear + @connection.send(:rename_column_for_alter, "bar_baz", "foo", "foo2") + assert_equal "SCHEMA", @subscriber.logged[0][1] + ensure + @connection.execute "DROP TABLE `bar_baz`" + end + + def test_get_and_release_advisory_lock + lock_name = "test lock'n'name" + + got_lock = @connection.get_advisory_lock(lock_name) + assert got_lock, "get_advisory_lock should have returned true but it didn't" + + assert_equal test_lock_free(lock_name), false, + "expected the test advisory lock to be held but it wasn't" + + released_lock = @connection.release_advisory_lock(lock_name) + assert released_lock, "expected release_advisory_lock to return true but it didn't" + + assert test_lock_free(lock_name), "expected the test lock to be available after releasing" + end + + def test_release_non_existent_advisory_lock + lock_name = "fake lock'n'name" + released_non_existent_lock = @connection.release_advisory_lock(lock_name) + assert_equal released_non_existent_lock, false, + "expected release_advisory_lock to return false when there was no lock to release" + end + + private + + def test_lock_free(lock_name) + @connection.select_value("SELECT IS_FREE_LOCK(#{@connection.quote(lock_name)})") == 1 + end +end diff --git a/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb new file mode 100644 index 0000000000..00a075e063 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/datetime_precision_quoting_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Mysql2DatetimePrecisionQuotingTest < ActiveRecord::Mysql2TestCase + setup do + @connection = ActiveRecord::Base.connection + end + + test "microsecond precision for MySQL gte 5.6.4" do + stub_version "5.6.4" do + assert_microsecond_precision + end + end + + test "no microsecond precision for MySQL lt 5.6.4" do + stub_version "5.6.3" do + assert_no_microsecond_precision + end + end + + test "microsecond precision for MariaDB gte 5.3.0" do + stub_version "5.5.5-10.1.8-MariaDB-log" do + assert_microsecond_precision + end + end + + test "no microsecond precision for MariaDB lt 5.3.0" do + stub_version "5.2.9-MariaDB" do + assert_no_microsecond_precision + end + end + + private + def assert_microsecond_precision + assert_match_quoted_microsecond_datetime(/\.000001\z/) + end + + def assert_no_microsecond_precision + assert_match_quoted_microsecond_datetime(/\d\z/) + end + + def assert_match_quoted_microsecond_datetime(match) + assert_match match, @connection.quoted_date(Time.now.change(usec: 1)) + end + + def stub_version(full_version_string) + @connection.stub(:full_version, full_version_string) do + @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) + yield + end + ensure + @connection.remove_instance_variable(:@version) if @connection.instance_variable_defined?(:@version) + 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..832f5d61d1 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/enum_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Mysql2EnumTest < ActiveRecord::Mysql2TestCase + class EnumTest < ActiveRecord::Base + end + + def test_enum_limit + column = EnumTest.columns_hash["enum_column"] + assert_equal 8, column.limit + end + + def test_should_not_be_unsigned + column = EnumTest.columns_hash["enum_column"] + assert_not_predicate column, :unsigned? + end + + def test_should_not_be_bigint + column = EnumTest.columns_hash["enum_column"] + assert_not_predicate column, :bigint? + 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..b8e778f0b0 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/explain_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/post" + +class Mysql2ExplainTest < ActiveRecord::Mysql2TestCase + fixtures :authors + + def test_explain_for_one_query + explain = Author.where(id: 1).explain + assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain + assert_match %r(authors |.* const), explain + end + + def test_explain_with_eager_loading + explain = Author.where(id: 1).includes(:posts).explain + assert_match %(EXPLAIN for: SELECT `authors`.* FROM `authors` WHERE `authors`.`id` = 1), explain + assert_match %r(authors |.* const), explain + assert_match %(EXPLAIN for: SELECT `posts`.* FROM `posts` WHERE `posts`.`author_id` = 1), explain + assert_match %r(posts |.* ALL), explain + end +end diff --git a/activerecord/test/cases/adapters/mysql2/json_test.rb b/activerecord/test/cases/adapters/mysql2/json_test.rb new file mode 100644 index 0000000000..de78ba91f5 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/json_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/json_shared_test_cases" + +if ActiveRecord::Base.connection.supports_json? + class Mysql2JSONTest < ActiveRecord::Mysql2TestCase + include JSONSharedTestCases + self.use_transactional_tests = false + + def setup + super + @connection.create_table("json_data_type") do |t| + t.json "payload" + t.json "settings" + end + end + + private + def column_type + :json + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb new file mode 100644 index 0000000000..7bc86476b6 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/mysql2_adapter_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/ddl_helper" + +class Mysql2AdapterTest < ActiveRecord::Mysql2TestCase + include DdlHelper + + def setup + @conn = ActiveRecord::Base.connection + end + + def test_exec_query_nothing_raises_with_no_result_queries + assert_nothing_raised do + with_example_table do + @conn.exec_query("INSERT INTO ex (number) VALUES (1)") + @conn.exec_query("DELETE FROM ex WHERE number = 1") + end + end + end + + def test_columns_for_distinct_zero_orders + assert_equal "posts.id", + @conn.columns_for_distinct("posts.id", []) + end + + def test_columns_for_distinct_one_order + assert_equal "posts.created_at AS alias_0, posts.id", + @conn.columns_for_distinct("posts.id", ["posts.created_at desc"]) + end + + def test_columns_for_distinct_few_orders + assert_equal "posts.created_at AS alias_0, posts.position AS alias_1, posts.id", + @conn.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"]) + end + + def test_columns_for_distinct_with_case + assert_equal( + "CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0, posts.id", + @conn.columns_for_distinct("posts.id", + ["CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END"]) + ) + end + + def test_columns_for_distinct_blank_not_nil_orders + assert_equal "posts.created_at AS alias_0, posts.id", + @conn.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.created_at AS alias_0, posts.id", + @conn.columns_for_distinct("posts.id", [order]) + end + + def test_errors_for_bigint_fks_on_integer_pk_table + # table old_cars has primary key of integer + + error = assert_raises(ActiveRecord::MismatchedForeignKey) do + @conn.add_reference :engines, :old_car + @conn.add_foreign_key :engines, :old_cars + end + + assert_match "Column `old_car_id` on table `engines` has a type of `bigint(20)`", error.message + assert_not_nil error.cause + @conn.exec_query("ALTER TABLE engines DROP COLUMN old_car_id") + end + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + @conn.insert("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.update("UPDATE `engines` SET `engines`.`car_id` = '9989' WHERE `engines`.`car_id` = '138853948594'") + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("DELETE FROM `engines` where `engines`.`car_id` = '138853948594'") + end + end + end + + def test_errors_when_a_replace_query_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("REPLACE INTO `engines` SET `engines`.`car_id` = '249823948'") + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + @conn.execute("INSERT INTO `engines` (`car_id`) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute("SELECT `engines`.* FROM `engines` WHERE `engines`.`car_id` = '138853948594'").entries.count + end + end + + def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes + @conn.while_preventing_writes do + assert_equal 2, @conn.execute("SHOW FULL FIELDS FROM `engines`").entries.count + end + end + + def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes + @conn.while_preventing_writes do + assert_nil @conn.execute("SET NAMES utf8") + end + end + + private + + def with_example_table(definition = "id int auto_increment primary key, number int, data varchar(255)", &block) + super(@conn, "ex", definition, &block) + 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..d7d9a2d732 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/schema_migrations_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "cases/helper" + +class SchemaMigrationsTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + + 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 + with_encoding_utf8mb4 do + table_name = ActiveRecord::SchemaMigration.table_name + connection.drop_table table_name, if_exists: true + + ActiveRecord::SchemaMigration.create_table + + assert connection.column_exists?(table_name, :version, :string) + end + end + + def test_initializes_internal_metadata_for_encoding_utf8mb4 + with_encoding_utf8mb4 do + table_name = ActiveRecord::InternalMetadata.table_name + connection.drop_table table_name, if_exists: true + + ActiveRecord::InternalMetadata.create_table + + assert connection.column_exists?(table_name, :key, :string) + end + ensure + ActiveRecord::InternalMetadata[:environment] = connection.migration_context.current_environment + end + + private + + def with_encoding_utf8mb4 + database_name = connection.current_database + database_info = connection.select_one("SELECT * FROM information_schema.schemata WHERE schema_name = '#{database_name}'") + + original_charset = database_info["DEFAULT_CHARACTER_SET_NAME"] + original_collation = database_info["DEFAULT_COLLATION_NAME"] + + execute("ALTER DATABASE #{database_name} DEFAULT CHARACTER SET utf8mb4") + + yield + ensure + execute("ALTER DATABASE #{database_name} DEFAULT CHARACTER SET #{original_charset} COLLATE #{original_collation}") + end + + def connection + @connection ||= ActiveRecord::Base.connection + end + + def execute(sql) + connection.execute(sql) + 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..1283b0642c --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/schema_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +module ActiveRecord + module ConnectionAdapters + class Mysql2SchemaTest < ActiveRecord::Mysql2TestCase + 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.inheritance_column = :disabled + self.table_name = "#{db}.#{table}" + def self.name; "Post"; end + end + end + + def test_float_limits + @connection.create_table :mysql_doubles do |t| + t.float :float_no_limit + t.float :float_short, limit: 5 + t.float :float_long, limit: 53 + + t.float :float_23, limit: 23 + t.float :float_24, limit: 24 + t.float :float_25, limit: 25 + end + + column_no_limit = @connection.columns(:mysql_doubles).find { |c| c.name == "float_no_limit" } + column_short = @connection.columns(:mysql_doubles).find { |c| c.name == "float_short" } + column_long = @connection.columns(:mysql_doubles).find { |c| c.name == "float_long" } + + column_23 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_23" } + column_24 = @connection.columns(:mysql_doubles).find { |c| c.name == "float_24" } + column_25 = @connection.columns(:mysql_doubles).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 + ensure + @connection.drop_table "mysql_doubles", if_exists: true + end + + def test_schema + assert @omgpost.first + end + + def test_primary_key + assert_equal "id", @omgpost.primary_key + end + + def test_data_source_exists? + name = @omgpost.table_name + assert @connection.data_source_exists?(name), "#{name} data_source should exist" + end + + def test_data_source_exists_wrong_schema + assert_not(@connection.data_source_exists?("#{@db_name}.zomg"), "data_source 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(&: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 + + unless mysql_enforcing_gtid_consistency? + 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 +end + +class Mysql2AnsiQuotesTest < ActiveRecord::Mysql2TestCase + def setup + @connection = ActiveRecord::Base.connection + @connection.execute("SET SESSION sql_mode='ANSI_QUOTES'") + end + + def teardown + @connection.reconnect! + end + + def test_primary_key_method_with_ansi_quotes + assert_equal "id", @connection.primary_key("topics") + end + + def test_foreign_keys_method_with_ansi_quotes + fks = @connection.foreign_keys("lessons_students") + assert_equal([["lessons_students", "students", :cascade]], + fks.map { |fk| [fk.from_table, fk.to_table, fk.on_delete] }) + end +end diff --git a/activerecord/test/cases/adapters/mysql2/sp_test.rb b/activerecord/test/cases/adapters/mysql2/sp_test.rb new file mode 100644 index 0000000000..7b6dce71e9 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/sp_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" + +class Mysql2StoredProcedureTest < ActiveRecord::Mysql2TestCase + fixtures :topics + + def setup + @connection = ActiveRecord::Base.connection + unless ActiveRecord::Base.connection.version >= "5.6.0" + skip("no stored procedure support") + end + end + + # Test that MySQL allows multiple results for stored procedures + # + # In MySQL 5.6, CLIENT_MULTI_RESULTS is enabled by default. + # https://dev.mysql.com/doc/refman/5.6/en/call.html + def test_multi_results + rows = @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 'Mysql2Adapter.select_rows'" + end + + def test_multi_results_from_select_one + row = @connection.select_one("CALL topics(1);") + assert_equal "David", row["author_name"] + assert @connection.active?, "Bad connection use by 'Mysql2Adapter.select_one'" + end + + def test_multi_results_from_find_by_sql + topics = Topic.find_by_sql "CALL topics(3);" + assert_equal 3, topics.size + assert @connection.active?, "Bad connection use by 'Mysql2Adapter.select'" + 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..e10642cbb4 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/sql_types_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Mysql2SqlTypesTest < ActiveRecord::Mysql2TestCase + 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", type_to_sql(:binary, 4096) + assert_equal "blob", type_to_sql(:binary) + end + + def type_to_sql(type, limit = nil) + ActiveRecord::Base.connection.type_to_sql(type, limit: limit) + end +end diff --git a/activerecord/test/cases/adapters/mysql2/table_options_test.rb b/activerecord/test/cases/adapters/mysql2/table_options_test.rb new file mode 100644 index 0000000000..1c92df940f --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/table_options_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class Mysql2TableOptionsTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table "mysql_table_options", if_exists: true + end + + test "table options with ENGINE" do + @connection.create_table "mysql_table_options", force: true, options: "ENGINE=MyISAM" + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{ENGINE=MyISAM}, options + end + + test "table options with ROW_FORMAT" do + @connection.create_table "mysql_table_options", force: true, options: "ROW_FORMAT=REDUNDANT" + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{ROW_FORMAT=REDUNDANT}, options + end + + test "table options with CHARSET" do + @connection.create_table "mysql_table_options", force: true, options: "CHARSET=utf8mb4" + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{CHARSET=utf8mb4}, options + end + + test "table options with COLLATE" do + @connection.create_table "mysql_table_options", force: true, options: "COLLATE=utf8mb4_bin" + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{COLLATE=utf8mb4_bin}, options + end +end + +class Mysql2DefaultEngineOptionSchemaDumpTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + def setup + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + end + + def teardown + ActiveRecord::Base.connection.drop_table "mysql_table_options", if_exists: true + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + test "schema dump includes ENGINE=InnoDB if not provided" do + ActiveRecord::Base.connection.create_table "mysql_table_options", force: true + + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{ENGINE=InnoDB}, options + end + + test "schema dump includes ENGINE=InnoDB in legacy migrations" do + migration = Class.new(ActiveRecord::Migration[5.1]) do + def migrate(x) + create_table "mysql_table_options", force: true + end + end.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + output = dump_table_schema("mysql_table_options") + options = %r{create_table "mysql_table_options", options: "(?<options>.*)"}.match(output)[:options] + assert_match %r{ENGINE=InnoDB}, options + end +end + +class Mysql2DefaultEngineOptionSqlOutputTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + + def setup + @logger_was = ActiveRecord::Base.logger + @log = StringIO.new + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log) + ActiveRecord::Migration.verbose = false + end + + def teardown + ActiveRecord::Base.logger = @logger_was + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::Base.connection.drop_table "mysql_table_options", if_exists: true + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + test "new migrations do not contain default ENGINE=InnoDB option" do + ActiveRecord::Base.connection.create_table "mysql_table_options", force: true + + assert_no_match %r{ENGINE=InnoDB}, @log.string + end + + test "legacy migrations contain default ENGINE=InnoDB option" do + migration = Class.new(ActiveRecord::Migration[5.1]) do + def migrate(x) + create_table "mysql_table_options", force: true + end + end.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert_match %r{ENGINE=InnoDB}, @log.string + end +end diff --git a/activerecord/test/cases/adapters/mysql2/transaction_test.rb b/activerecord/test/cases/adapters/mysql2/transaction_test.rb new file mode 100644 index 0000000000..52e283f247 --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/transaction_test.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +module ActiveRecord + class Mysql2TransactionTest < ActiveRecord::Mysql2TestCase + self.use_transactional_tests = false + + class Sample < ActiveRecord::Base + self.table_name = "samples" + end + + setup do + @abort, Thread.abort_on_exception = Thread.abort_on_exception, false + Thread.report_on_exception, @original_report_on_exception = false, Thread.report_on_exception + + @connection = ActiveRecord::Base.connection + @connection.clear_cache! + + @connection.transaction do + @connection.drop_table "samples", if_exists: true + @connection.create_table("samples") do |t| + t.integer "value" + end + end + + Sample.reset_column_information + end + + teardown do + @connection.drop_table "samples", if_exists: true + + Thread.abort_on_exception = @abort + Thread.report_on_exception = @original_report_on_exception + end + + test "raises Deadlocked when a deadlock is encountered" do + assert_raises(ActiveRecord::Deadlocked) do + barrier = Concurrent::CyclicBarrier.new(2) + + s1 = Sample.create value: 1 + s2 = Sample.create value: 2 + + thread = Thread.new do + Sample.transaction do + s1.lock! + barrier.wait + s2.update value: 1 + end + end + + begin + Sample.transaction do + s2.lock! + barrier.wait + s1.update value: 2 + end + ensure + thread.join + end + end + end + + test "raises LockWaitTimeout when lock wait timeout exceeded" do + assert_raises(ActiveRecord::LockWaitTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET innodb_lock_wait_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET innodb_lock_wait_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises StatementTimeout when statement timeout exceeded" do + skip unless ActiveRecord::Base.connection.show_variable("max_execution_time") + assert_raises(ActiveRecord::StatementTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET max_execution_time = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET max_execution_time = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when canceling statement due to user request" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch.count_down + sleep(0.5) + conn = Sample.connection + pid = conn.query_value("SELECT id FROM information_schema.processlist WHERE info LIKE '% FOR UPDATE'") + conn.execute("KILL QUERY #{pid}") + end + end + + begin + Sample.transaction do + latch.wait + Sample.lock.find(s.id) + end + ensure + thread.join + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb new file mode 100644 index 0000000000..97da96003d --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/unsigned_type_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class Mysql2UnsignedTypeTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + class UnsignedType < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table("unsigned_types", force: true) do |t| + t.integer :unsigned_integer, unsigned: true + t.bigint :unsigned_bigint, unsigned: true + t.float :unsigned_float, unsigned: true + t.decimal :unsigned_decimal, unsigned: true, precision: 10, scale: 2 + t.column :unsigned_zerofill, "int unsigned zerofill" + end + end + + teardown do + @connection.drop_table "unsigned_types", if_exists: true + end + + test "unsigned int max value is in range" do + assert expected = UnsignedType.create(unsigned_integer: 4294967295) + assert_equal expected, UnsignedType.find_by(unsigned_integer: 4294967295) + end + + test "minus value is out of range" do + assert_raise(ActiveModel::RangeError) do + UnsignedType.create(unsigned_integer: -10) + end + assert_raise(ActiveModel::RangeError) do + UnsignedType.create(unsigned_bigint: -10) + end + assert_raise(ActiveRecord::RangeError) do + UnsignedType.create(unsigned_float: -10.0) + end + assert_raise(ActiveRecord::RangeError) do + UnsignedType.create(unsigned_decimal: -10.0) + end + end + + test "schema definition can use unsigned as the type" do + @connection.change_table("unsigned_types") do |t| + t.unsigned_integer :unsigned_integer_t + t.unsigned_bigint :unsigned_bigint_t + t.unsigned_float :unsigned_float_t + t.unsigned_decimal :unsigned_decimal_t, precision: 10, scale: 2 + end + + @connection.columns("unsigned_types").select { |c| /^unsigned_/.match?(c.name) }.each do |column| + assert_predicate column, :unsigned? + end + end + + test "schema dump includes unsigned option" do + schema = dump_table_schema "unsigned_types" + assert_match %r{t\.integer\s+"unsigned_integer",\s+unsigned: true$}, schema + assert_match %r{t\.bigint\s+"unsigned_bigint",\s+unsigned: true$}, schema + assert_match %r{t\.float\s+"unsigned_float",\s+unsigned: true$}, schema + assert_match %r{t\.decimal\s+"unsigned_decimal",\s+precision: 10,\s+scale: 2,\s+unsigned: true$}, schema + end +end diff --git a/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb new file mode 100644 index 0000000000..8494acee3b --- /dev/null +++ b/activerecord/test/cases/adapters/mysql2/virtual_column_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +if ActiveRecord::Base.connection.supports_virtual_columns? + class Mysql2VirtualColumnTest < ActiveRecord::Mysql2TestCase + include SchemaDumpingHelper + + self.use_transactional_tests = false + + class VirtualColumn < ActiveRecord::Base + end + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :virtual_columns, force: true do |t| + t.string :name + t.virtual :upper_name, type: :string, as: "UPPER(`name`)" + t.virtual :name_length, type: :integer, as: "LENGTH(`name`)", stored: true + t.virtual :name_octet_length, type: :integer, as: "OCTET_LENGTH(`name`)", stored: true + end + VirtualColumn.create(name: "Rails") + end + + def teardown + @connection.drop_table :virtual_columns, if_exists: true + VirtualColumn.reset_column_information + end + + def test_virtual_column + column = VirtualColumn.columns_hash["upper_name"] + assert_predicate column, :virtual? + assert_match %r{\bVIRTUAL\b}, column.extra + assert_equal "RAILS", VirtualColumn.take.upper_name + end + + def test_stored_column + column = VirtualColumn.columns_hash["name_length"] + assert_predicate column, :virtual? + assert_match %r{\b(?:STORED|PERSISTENT)\b}, column.extra + assert_equal 5, VirtualColumn.take.name_length + end + + def test_change_table + @connection.change_table :virtual_columns do |t| + t.virtual :lower_name, type: :string, as: "LOWER(name)" + end + VirtualColumn.reset_column_information + column = VirtualColumn.columns_hash["lower_name"] + assert_predicate column, :virtual? + assert_match %r{\bVIRTUAL\b}, column.extra + assert_equal "rails", VirtualColumn.take.lower_name + end + + def test_schema_dumping + output = dump_table_schema("virtual_columns") + assert_match(/t\.virtual\s+"upper_name",\s+type: :string,\s+as: "(?:UPPER|UCASE)\(`name`\)"$/i, output) + assert_match(/t\.virtual\s+"name_length",\s+type: :integer,\s+as: "(?:octet_length|length)\(`name`\)",\s+stored: true$/i, output) + assert_match(/t\.virtual\s+"name_octet_length",\s+type: :integer,\s+as: "(?:octet_length|length)\(`name`\)",\s+stored: true$/i, output) + end + 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..62efaf3bfe --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/active_schema_test.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlActiveSchemaTest < ActiveRecord::PostgreSQLTestCase + def setup + ActiveRecord::Base.connection.materialize_transactions + + 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.define_method(:index_name_exists?) { |*| 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 UNIQUE INDEX "index_people_on_lower_last_name" ON "people" (lower(last_name))) + assert_equal expected, add_index(:people, "lower(last_name)", unique: true) + + expected = %(CREATE UNIQUE INDEX "index_people_on_last_name_varchar_pattern_ops" ON "people" (last_name varchar_pattern_ops)) + assert_equal expected, add_index(:people, "last_name varchar_pattern_ops", unique: true) + + expected = %(CREATE INDEX CONCURRENTLY "index_people_on_last_name" ON "people" ("last_name")) + assert_equal expected, add_index(:people, :last_name, algorithm: :concurrently) + + expected = %(CREATE INDEX "index_people_on_last_name_and_first_name" ON "people" ("last_name" DESC, "first_name" ASC)) + assert_equal expected, add_index(:people, [:last_name, :first_name], order: { last_name: :desc, first_name: :asc }) + assert_equal expected, add_index(:people, ["last_name", :first_name], order: { last_name: :desc, "first_name" => :asc }) + + %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) + + expected = %(CREATE UNIQUE INDEX "index_people_on_last_name" ON "people" USING #{type} ("last_name") WHERE state = 'active') + assert_equal expected, add_index(:people, :last_name, using: type, unique: true, where: "state = 'active'") + + expected = %(CREATE UNIQUE INDEX "index_people_on_lower_last_name" ON "people" USING #{type} (lower(last_name))) + assert_equal expected, add_index(:people, "lower(last_name)", using: type, unique: true) + end + + expected = %(CREATE INDEX "index_people_on_last_name" ON "people" USING gist ("last_name" bpchar_pattern_ops)) + assert_equal expected, add_index(:people, :last_name, using: :gist, opclass: { last_name: :bpchar_pattern_ops }) + + expected = %(CREATE INDEX "index_people_on_last_name_and_first_name" ON "people" ("last_name" DESC NULLS LAST, "first_name" ASC)) + assert_equal expected, add_index(:people, [:last_name, :first_name], order: { last_name: "DESC NULLS LAST", first_name: :asc }) + + expected = %(CREATE INDEX "index_people_on_last_name" ON "people" ("last_name" NULLS FIRST)) + assert_equal expected, add_index(:people, :last_name, order: "NULLS FIRST") + + assert_raise ArgumentError do + add_index(:people, :last_name, algorithm: :copy) + end + + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.remove_method :index_name_exists? + end + + def test_remove_index + # remove_index calls index_name_for_remove which can't work since execute is stubbed + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.define_method(:index_name_for_remove) do |*| + "index_people_on_last_name" + end + + expected = %(DROP INDEX CONCURRENTLY "index_people_on_last_name") + assert_equal expected, remove_index(:people, name: "index_people_on_last_name", algorithm: :concurrently) + + assert_raise ArgumentError do + add_index(:people, :last_name, algorithm: :copy) + end + + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.remove_method :index_name_for_remove + end + + def test_remove_index_when_name_is_specified + expected = %(DROP INDEX CONCURRENTLY "index_people_on_last_name") + assert_equal expected, remove_index(:people, name: "index_people_on_last_name", algorithm: :concurrently) + end + + def test_remove_index_with_wrong_option + assert_raises ArgumentError do + remove_index(:people, coulmn: :last_name) + end + 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..42618c2ec3 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/array_test.rb @@ -0,0 +1,390 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + include InTimeZone + + class PgArray < ActiveRecord::Base + self.table_name = "pg_arrays" + end + + def setup + @connection = ActiveRecord::Base.connection + + enable_extension!("hstore", @connection) + + @connection.transaction do + @connection.create_table("pg_arrays") do |t| + t.string "tags", array: true, limit: 255 + t.integer "ratings", array: true + t.datetime :datetimes, array: true + t.hstore :hstores, array: true + t.decimal :decimals, array: true, default: [], precision: 10, scale: 2 + t.timestamp :timestamps, array: true, default: [], precision: 6 + end + end + PgArray.reset_column_information + @column = PgArray.columns_hash["tags"] + @type = PgArray.type_for_attribute("tags") + end + + teardown do + @connection.drop_table "pg_arrays", if_exists: true + disable_extension!("hstore", @connection) + end + + def test_column + assert_equal :string, @column.type + assert_equal "character varying(255)", @column.sql_type + assert_predicate @column, :array? + assert_not_predicate @type, :binary? + + ratings_column = PgArray.columns_hash["ratings"] + assert_equal :integer, ratings_column.type + assert_predicate ratings_column, :array? + end + + def test_not_compatible_with_serialize_array + new_klass = Class.new(PgArray) do + serialize :tags, Array + end + assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do + new_klass.new + end + end + + class MyTags + def initialize(tags); @tags = tags end + def to_a; @tags end + def self.load(tags); new(tags) end + def self.dump(object); object.to_a end + end + + def test_array_with_serialized_attributes + new_klass = Class.new(PgArray) do + serialize :tags, MyTags + end + + new_klass.create!(tags: MyTags.new(["one", "two"])) + record = new_klass.first + + assert_instance_of MyTags, record.tags + assert_equal ["one", "two"], record.tags.to_a + + record.tags = MyTags.new(["three", "four"]) + record.save! + + assert_equal ["three", "four"], record.reload.tags.to_a + 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_predicate 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"], @type.deserialize("{1,2,3}")) + assert_equal([], @type.deserialize("{}")) + assert_equal([nil], @type.deserialize("{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_schema_dump_with_shorthand + output = dump_table_schema "pg_arrays" + assert_match %r[t\.string\s+"tags",\s+limit: 255,\s+array: true], output + assert_match %r[t\.integer\s+"ratings",\s+array: true], output + assert_match %r[t\.decimal\s+"decimals",\s+precision: 10,\s+scale: 2,\s+default: \[\],\s+array: true], output + 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_insert_fixtures + tag_values = ["val1", "val2", "val3_with_'_multiple_quote_'_chars"] + assert_deprecated do + @connection.insert_fixtures([{ "tags" => tag_values }], "pg_arrays") + end + assert_equal(PgArray.last.tags, tag_values) + end + + def test_attribute_for_inspect_for_array_field + record = PgArray.new { |a| a.ratings = (1..10).to_a } + assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]", record.attribute_for_inspect(:ratings)) + end + + def test_attribute_for_inspect_for_array_field_for_large_array + record = PgArray.new { |a| a.ratings = (1..11).to_a } + assert_equal("[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]", 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_not_predicate x, :changed? + end + + def test_quoting_non_standard_delimiters + strings = ["hello,", "world;"] + oid = ActiveRecord::ConnectionAdapters::PostgreSQL::OID + comma_delim = oid::Array.new(ActiveRecord::Type::String.new, ",") + semicolon_delim = oid::Array.new(ActiveRecord::Type::String.new, ";") + conn = PgArray.connection + + assert_equal %({"hello,",world;}), conn.type_cast(comma_delim.serialize(strings)) + assert_equal %({hello,;"world;"}), conn.type_cast(semicolon_delim.serialize(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_predicate 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_predicate 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 + + def test_assigning_non_array_value + record = PgArray.new(tags: "not-an-array") + assert_equal [], record.tags + assert_equal "not-an-array", record.tags_before_type_cast + assert record.save + assert_equal record.tags, record.reload.tags + end + + def test_assigning_empty_string + record = PgArray.new(tags: "") + assert_equal [], record.tags + assert_equal "", record.tags_before_type_cast + assert record.save + assert_equal record.tags, record.reload.tags + end + + def test_assigning_valid_pg_array_literal + record = PgArray.new(tags: "{1,2,3}") + assert_equal ["1", "2", "3"], record.tags + assert_equal "{1,2,3}", record.tags_before_type_cast + assert record.save + assert_equal record.tags, record.reload.tags + end + + def test_where_by_attribute_with_array + tags = ["black", "blue"] + record = PgArray.create!(tags: tags) + assert_equal record, PgArray.where(tags: tags).take + end + + def test_uniqueness_validation + klass = Class.new(PgArray) do + validates_uniqueness_of :tags + + def self.model_name; ActiveModel::Name.new(PgArray) end + end + e1 = klass.create("tags" => ["black", "blue"]) + assert e1.persisted?, "Saving e1" + + e2 = klass.create("tags" => ["black", "blue"]) + assert_not e2.persisted?, "e2 shouldn't be valid" + assert e2.errors[:tags].any?, "Should have errors for tags" + assert_equal ["has already been taken"], e2.errors[:tags], "Should have uniqueness message for tags" + end + + def test_encoding_arrays_of_utf8_strings + arrays_of_utf8_strings = %w(nový ファイル) + assert_equal arrays_of_utf8_strings, @type.deserialize(@type.serialize(arrays_of_utf8_strings)) + assert_equal [arrays_of_utf8_strings], @type.deserialize(@type.serialize([arrays_of_utf8_strings])) + end + + def test_precision_is_respected_on_timestamp_columns + time = Time.now.change(usec: 123) + record = PgArray.create!(timestamps: [time]) + + assert_equal 123, record.timestamps.first.usec + record.reload + assert_equal 123, record.timestamps.first.usec + 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..c8e728bbb6 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/bit_string_test.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" +require "support/schema_dumping_helper" + +class PostgresqlBitStringTest < ActiveRecord::PostgreSQLTestCase + 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 + t.bit :another_bit + t.bit_varying :another_bit_varying + end + end + + def teardown + return unless @connection + @connection.drop_table "postgresql_bit_strings", if_exists: true + 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_predicate column, :array? + + type = PostgresqlBitString.type_for_attribute("a_bit") + assert_not_predicate type, :binary? + 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_predicate column, :array? + + type = PostgresqlBitString.type_for_attribute("a_bit_varying") + assert_not_predicate type, :binary? + 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 + + if ActiveRecord::Base.connection.prepared_statements + 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: "F" } + end + end + + def test_roundtrip + record = PostgresqlBitString.create!(a_bit: "00001010", a_bit_varying: "0101") + assert_equal "00001010", record.a_bit + assert_equal "0101", record.a_bit_varying + assert_nil record.another_bit + assert_nil record.another_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..3988c2adca --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/bytea_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlByteaTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + 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"] + @type = ByteaDataType.type_for_attribute("payload") + end + + teardown do + @connection.drop_table "bytea_data_type", if_exists: true + end + + def test_column + assert @column.is_a?(ActiveRecord::ConnectionAdapters::PostgreSQLColumn) + assert_equal :binary, @column.type + end + + def test_binary_columns_are_limitless_the_upper_limit_is_one_GB + assert_equal "bytea", @connection.type_to_sql(:binary, limit: 100_000) + assert_raise ActiveRecord::ActiveRecordError do + @connection.type_to_sql(:binary, limit: 4294967295) + end + 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", @type.deserialize(data).encoding.name) + end + + def test_type_cast_binary_value + data = (+"\u001F\x8B").force_encoding("BINARY") + assert_equal(data, @type.deserialize(data)) + end + + def test_type_case_nil + assert_nil(@type.deserialize(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_nil(record.payload) + record.delete + end + + def test_write_value + data = "\u001F" + record = ByteaDataType.create(payload: data) + assert_not_predicate 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") + other_conn.execute("SET escape_string_warning = off") + end.join + + test_via_to_sql + end + + def test_write_binary + data = File.read(File.join(__dir__, "..", "..", "..", "assets", "example.log")) + assert(data.size > 1) + record = ByteaDataType.create(payload: data) + assert_not_predicate 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_predicate record, :new_record? + assert_nil(record.payload) + assert_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 + + def test_schema_dumping + output = dump_table_schema("bytea_data_type") + assert_match %r{t\.binary\s+"payload"$}, output + assert_match %r{t\.binary\s+"serialized"$}, output + end +end diff --git a/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb new file mode 100644 index 0000000000..305e033642 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/case_insensitive_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlCaseInsensitiveTest < ActiveRecord::PostgreSQLTestCase + class Default < ActiveRecord::Base; end + + def test_case_insensitiveness + connection = ActiveRecord::Base.connection + table = Default.arel_table + + column = Default.columns_hash["char1"] + comparison = connection.case_insensitive_comparison table, :char1, column, nil + assert_match(/lower/i, comparison.to_sql) + + column = Default.columns_hash["char2"] + comparison = connection.case_insensitive_comparison table, :char2, column, nil + assert_match(/lower/i, comparison.to_sql) + + column = Default.columns_hash["char3"] + comparison = connection.case_insensitive_comparison table, :char3, column, nil + assert_match(/lower/i, comparison.to_sql) + + column = Default.columns_hash["multiline_default"] + comparison = connection.case_insensitive_comparison table, :multiline_default, column, nil + assert_match(/lower/i, comparison.to_sql) + end +end diff --git a/activerecord/test/cases/adapters/postgresql/change_schema_test.rb b/activerecord/test/cases/adapters/postgresql/change_schema_test.rb new file mode 100644 index 0000000000..6dba4f3e14 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/change_schema_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class Migration + class PGChangeSchemaTest < ActiveRecord::PostgreSQLTestCase + attr_reader :connection + + def setup + super + @connection = ActiveRecord::Base.connection + connection.create_table(:strings) do |t| + t.string :somedate + end + end + + def teardown + connection.drop_table :strings + end + + def test_change_string_to_date + connection.change_column :strings, :somedate, :timestamp, using: 'CAST("somedate" AS timestamp)' + assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type + end + + def test_change_type_with_symbol + connection.change_column :strings, :somedate, :timestamp, cast_as: :timestamp + assert_equal :datetime, connection.columns(:strings).find { |c| c.name == "somedate" }.type + end + + def test_change_type_with_array + connection.change_column :strings, :somedate, :timestamp, array: true, cast_as: :timestamp + column = connection.columns(:strings).find { |c| c.name == "somedate" } + assert_equal :datetime, column.type + assert_predicate column, :array? + end + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/cidr_test.rb b/activerecord/test/cases/adapters/postgresql/cidr_test.rb new file mode 100644 index 0000000000..f20958fbd2 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/cidr_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "cases/helper" +require "ipaddr" + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter < AbstractAdapter + class CidrTest < ActiveRecord::PostgreSQLTestCase + test "type casting IPAddr for database" do + type = OID::Cidr.new + ip = IPAddr.new("255.0.0.0/8") + ip2 = IPAddr.new("127.0.0.1") + + assert_equal "255.0.0.0/8", type.serialize(ip) + assert_equal "127.0.0.1/32", type.serialize(ip2) + end + + test "casting does nothing with non-IPAddr objects" do + type = OID::Cidr.new + + assert_equal "foo", type.serialize("foo") + end + end + end + 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..9eb0b7d99c --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/citext_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlCitextTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class Citext < ActiveRecord::Base + self.table_name = "citexts" + end + + def setup + @connection = ActiveRecord::Base.connection + + enable_extension!("citext", @connection) + + @connection.create_table("citexts") do |t| + t.citext "cival" + end + end + + teardown do + @connection.drop_table "citexts", if_exists: true + disable_extension!("citext", @connection) + 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_predicate column, :array? + + type = Citext.type_for_attribute("cival") + assert_not_predicate type, :binary? + 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 + + def test_schema_dump_with_shorthand + output = dump_table_schema("citexts") + assert_match %r[t\.citext "cival"], output + end +end diff --git a/activerecord/test/cases/adapters/postgresql/collation_test.rb b/activerecord/test/cases/adapters/postgresql/collation_test.rb new file mode 100644 index 0000000000..7468f4c4f8 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/collation_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlCollationTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :postgresql_collations, force: true do |t| + t.string :string_c, collation: "C" + t.text :text_posix, collation: "POSIX" + end + end + + def teardown + @connection.drop_table :postgresql_collations, if_exists: true + end + + test "string column with collation" do + column = @connection.columns(:postgresql_collations).find { |c| c.name == "string_c" } + assert_equal :string, column.type + assert_equal "C", column.collation + end + + test "text column with collation" do + column = @connection.columns(:postgresql_collations).find { |c| c.name == "text_posix" } + assert_equal :text, column.type + assert_equal "POSIX", column.collation + end + + test "add column with collation" do + @connection.add_column :postgresql_collations, :title, :string, collation: "C" + + column = @connection.columns(:postgresql_collations).find { |c| c.name == "title" } + assert_equal :string, column.type + assert_equal "C", column.collation + end + + test "change column with collation" do + @connection.add_column :postgresql_collations, :description, :string + @connection.change_column :postgresql_collations, :description, :text, collation: "POSIX" + + column = @connection.columns(:postgresql_collations).find { |c| c.name == "description" } + assert_equal :text, column.type + assert_equal "POSIX", column.collation + end + + test "schema dump includes collation" do + output = dump_table_schema("postgresql_collations") + assert_match %r{t\.string\s+"string_c",\s+collation: "C"$}, output + assert_match %r{t\.text\s+"text_posix",\s+collation: "POSIX"$}, output + 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..683066cdb3 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/composite_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +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.drop_table "postgresql_composites", if_exists: true + @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::PostgreSQLTestCase + 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_predicate column, :array? + + type = PostgresqlComposite.type_for_attribute("address") + assert_not_predicate type, :binary? + 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::PostgreSQLTestCase + include PostgresqlCompositeBehavior + + class FullAddressType < ActiveRecord::Type::Value + def type; :full_address end + + def deserialize(value) + if value =~ /\("?([^",]*)"?,"?([^",]*)"?\)/ + FullAddress.new($1, $2) + end + end + + def cast(value) + value + end + + def serialize(value) + return if value.nil? + "(#{value.city},#{value.street})" + end + end + + FullAddress = Struct.new(:city, :street) + + def setup + super + + @connection.send(: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_predicate column, :array? + + type = PostgresqlComposite.type_for_attribute("address") + assert_not_predicate type, :binary? + 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..40ab158c05 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/connection_test.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +module ActiveRecord + class PostgresqlConnectionTest < ActiveRecord::PostgreSQLTestCase + include ConnectionHelper + + class NonExistentTable < ActiveRecord::Base + end + + fixtures :comments + + def setup + super + @subscriber = SQLSubscriber.new + @connection = ActiveRecord::Base.connection + @connection.materialize_transactions + @subscription = ActiveSupport::Notifications.subscribe("sql.active_record", @subscriber) + end + + def teardown + ActiveSupport::Notifications.unsubscribe(@subscription) + super + end + + def test_truncate + count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i + assert_operator count, :>, 0 + ActiveRecord::Base.connection.truncate("comments") + count = ActiveRecord::Base.connection.execute("select count(*) from comments").first["count"].to_i + assert_equal 0, count + end + + def test_encoding + assert_queries(1) do + assert_not_nil @connection.encoding + end + end + + def test_collation + assert_queries(1) do + assert_not_nil @connection.collation + end + end + + def test_ctype + assert_queries(1) do + assert_not_nil @connection.ctype + end + 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 + assert_equal "SCHEMA", @subscriber.logged[0][1] + end + + def test_indexes_logs_name + @connection.indexes("items") + 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("@max_identifier_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 + + if ActiveRecord::Base.connection.prepared_statements + def test_statement_key_is_logged + bind = Relation::QueryAttribute.new(nil, 1, Type::Value.new) + @connection.exec_query("SELECT $1::integer", "SQL", [bind], prepare: true) + name = @subscriber.payloads.last[:statement_name] + assert name + res = @connection.exec_query("EXPLAIN (FORMAT JSON) EXECUTE #{name}(1)") + plan = res.column_types["QUERY PLAN"].deserialize res.rows.first.first + assert_operator plan.length, :>, 0 + end + end + + def test_reconnection_after_actual_disconnection_with_verify + original_connection_pid = @connection.query("select pg_backend_pid()") + + # Sanity check. + assert_predicate @connection, :active? + + 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) + + @connection.verify! + + assert_predicate @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." + ensure + # Repair all fixture connections so other tests won't break. + @fixture_connections.each(&:verify!) + 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 + + def test_set_session_timezone + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection(orig_connection.deep_merge(variables: { timezone: "America/New_York" })) + assert_equal "America/New_York", ActiveRecord::Base.connection.query_value("SHOW TIME ZONE") + end + end + + def test_get_and_release_advisory_lock + lock_id = 5295901941911233559 + list_advisory_locks = <<~SQL + SELECT locktype, + (classid::bigint << 32) | objid::bigint AS lock_id + FROM pg_locks + WHERE locktype = 'advisory' + SQL + + got_lock = @connection.get_advisory_lock(lock_id) + assert got_lock, "get_advisory_lock should have returned true but it didn't" + + advisory_lock = @connection.query(list_advisory_locks).find { |l| l[1] == lock_id } + assert advisory_lock, + "expected to find an advisory lock with lock_id #{lock_id} but there wasn't one" + + released_lock = @connection.release_advisory_lock(lock_id) + assert released_lock, "expected release_advisory_lock to return true but it didn't" + + advisory_locks = @connection.query(list_advisory_locks).select { |l| l[1] == lock_id } + assert_empty advisory_locks, + "expected to have released advisory lock with lock_id #{lock_id} but it was still held" + end + + def test_release_non_existent_advisory_lock + fake_lock_id = 2940075057017742022 + with_warning_suppression do + released_non_existent_lock = @connection.release_advisory_lock(fake_lock_id) + assert_equal released_non_existent_lock, false, + "expected release_advisory_lock to return false when there was no lock to release" + end + end + + def test_supports_ranges_is_deprecated + assert_deprecated { @connection.supports_ranges? } + end + + private + + def with_warning_suppression + log_level = @connection.client_min_messages + @connection.client_min_messages = "error" + yield + @connection.client_min_messages = log_level + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb b/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb new file mode 100644 index 0000000000..a02bae1453 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/create_unlogged_tables_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class UnloggedTablesTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + TABLE_NAME = "things" + LOGGED_FIELD = "relpersistence" + LOGGED_QUERY = "SELECT #{LOGGED_FIELD} FROM pg_class WHERE relname = '#{TABLE_NAME}'" + LOGGED = "p" + UNLOGGED = "u" + TEMPORARY = "t" + + class Thing < ActiveRecord::Base + self.table_name = TABLE_NAME + end + + def setup + @connection = ActiveRecord::Base.connection + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false + end + + teardown do + @connection.drop_table TABLE_NAME, if_exists: true + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = false + end + + def test_logged_by_default + @connection.create_table(TABLE_NAME) do |t| + end + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], LOGGED + end + + def test_unlogged_in_test_environment_when_unlogged_setting_enabled + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + + @connection.create_table(TABLE_NAME) do |t| + end + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], UNLOGGED + end + + def test_not_included_in_schema_dump + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + + @connection.create_table(TABLE_NAME) do |t| + end + assert_no_match(/unlogged/i, dump_table_schema(TABLE_NAME)) + end + + def test_not_changed_in_change_table + @connection.create_table(TABLE_NAME) do |t| + end + + ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.create_unlogged_tables = true + + @connection.change_table(TABLE_NAME) do |t| + t.column :name, :string + end + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], LOGGED + end + + def test_gracefully_handles_temporary_tables + @connection.create_table(TABLE_NAME, temporary: true) do |t| + end + + # Temporary tables are already unlogged, though this query results in a + # different result ("t" vs. "u"). This test is really just checking that we + # didn't try to run `CREATE TEMPORARY UNLOGGED TABLE`, which would result in + # a PostgreSQL error. + assert_equal @connection.execute(LOGGED_QUERY).first[LOGGED_FIELD], TEMPORARY + 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..b7535d5c9a --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/datatype_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/ddl_helper" + +class PostgresqlTime < ActiveRecord::Base +end + +class PostgresqlOid < ActiveRecord::Base +end + +class PostgresqlLtree < ActiveRecord::Base +end + +class PostgresqlDataTypeTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + + def setup + @connection = ActiveRecord::Base.connection + + @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 + [PostgresqlTime, PostgresqlOid].each(&:delete_all) + end + + def test_data_type_of_time_types + assert_equal :interval, @first_time.column_for_attribute(:time_interval).type + assert_equal :interval, @first_time.column_for_attribute(:scaled_time_interval).type + end + + def test_data_type_of_oid_types + assert_equal :oid, @first_oid.column_for_attribute(:obj_id).type + 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_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 + + def test_text_columns_are_limitless_the_upper_limit_is_one_GB + assert_equal "text", @connection.type_to_sql(:text, limit: 100_000) + assert_raise ActiveRecord::ActiveRecordError do + @connection.type_to_sql(:text, limit: 4294967295) + end + end +end + +class PostgresqlInternalDataTypeTest < ActiveRecord::PostgreSQLTestCase + 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/date_test.rb b/activerecord/test/cases/adapters/postgresql/date_test.rb new file mode 100644 index 0000000000..a86abac2be --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/date_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +class PostgresqlDateTest < ActiveRecord::PostgreSQLTestCase + def test_load_infinity_and_beyond + topic = Topic.find_by_sql("SELECT 'infinity'::date AS last_read").first + assert topic.last_read.infinite?, "timestamp should be infinite" + assert_operator topic.last_read, :>, 0 + + topic = Topic.find_by_sql("SELECT '-infinity'::date AS last_read").first + assert topic.last_read.infinite?, "timestamp should be infinite" + assert_operator topic.last_read, :<, 0 + end + + def test_save_infinity_and_beyond + topic = Topic.create!(last_read: 1.0 / 0.0) + assert_equal(1.0 / 0.0, topic.last_read) + + topic = Topic.create!(last_read: -1.0 / 0.0) + assert_equal(-1.0 / 0.0, topic.last_read) + end + + def test_bc_date + date = Date.new(0) - 1.week + topic = Topic.create!(last_read: date) + assert_equal date, Topic.find(topic.id).last_read + end + + def test_bc_date_leap_year + date = Time.utc(-4, 2, 29).to_date + topic = Topic.create!(last_read: date) + assert_equal date, Topic.find(topic.id).last_read + end + + def test_bc_date_year_zero + date = Time.utc(0, 4, 7).to_date + topic = Topic.create!(last_read: date) + assert_equal date, Topic.find(topic.id).last_read + 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..eeaad94c27 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/domain_test.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class PostgresqlDomainTest < ActiveRecord::PostgreSQLTestCase + 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.drop_table "postgresql_domains", if_exists: true + @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_not_predicate column, :array? + + type = PostgresqlDomain.type_for_attribute("price") + assert_not_predicate type, :binary? + 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("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..416a2b141b --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/enum_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class PostgresqlEnumTest < ActiveRecord::PostgreSQLTestCase + 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.drop_table "postgresql_enums", if_exists: true + @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_predicate column, :array? + + type = PostgresqlEnum.type_for_attribute("current_mood") + assert_not_predicate type, :binary? + 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_predicate stderr_output, :blank? + end + + def test_enum_type_cast + enum = PostgresqlEnum.new + enum.current_mood = :happy + + assert_equal "happy", enum.current_mood + end + + def test_assigning_enum_to_nil + model = PostgresqlEnum.new(current_mood: nil) + + assert_nil model.current_mood + assert model.save + assert_nil model.reload.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..be525383e9 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/explain_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/post" + +class PostgreSQLExplainTest < ActiveRecord::PostgreSQLTestCase + fixtures :authors + + def test_explain_for_one_query + explain = Author.where(id: 1).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain + assert_match %(QUERY PLAN), explain + end + + def test_explain_with_eager_loading + explain = Author.where(id: 1).includes(:posts).explain + assert_match %(QUERY PLAN), explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\$1 \[\["id", 1\]\]|1)), explain + assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\$1 \[\["author_id", 1\]\]|1)), explain + 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..df97ab11e7 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/extension_migration_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlExtensionMigrationTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + + class EnableHstore < ActiveRecord::Migration::Current + def change + enable_extension "hstore" + end + end + + class DisableHstore < ActiveRecord::Migration::Current + def change + disable_extension "hstore" + end + end + + def setup + super + + @connection = ActiveRecord::Base.connection + + @old_schema_migration_table_name = ActiveRecord::SchemaMigration.table_name + @old_table_name_prefix = ActiveRecord::Base.table_name_prefix + @old_table_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_table_name_prefix + ActiveRecord::Base.table_name_suffix = @old_table_name_suffix + ActiveRecord::SchemaMigration.delete_all rescue nil + ActiveRecord::Migration.verbose = true + ActiveRecord::SchemaMigration.table_name = @old_schema_migration_table_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/foreign_table_test.rb b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb new file mode 100644 index 0000000000..69339c8a31 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/foreign_table_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/professor" + +if ActiveRecord::Base.connection.supports_foreign_tables? + class ForeignTableTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class ForeignProfessor < ActiveRecord::Base + self.table_name = "foreign_professors" + end + + class ForeignProfessorWithPk < ForeignProfessor + self.primary_key = "id" + end + + def setup + @professor = Professor.create(name: "Nicola") + + @connection = ActiveRecord::Base.connection + enable_extension!("postgres_fdw", @connection) + + foreign_db_config = ARTest.connection_config["arunit2"] + @connection.execute <<~SQL + CREATE SERVER foreign_server + FOREIGN DATA WRAPPER postgres_fdw + OPTIONS (dbname '#{foreign_db_config["database"]}') + SQL + + @connection.execute <<~SQL + CREATE USER MAPPING FOR CURRENT_USER + SERVER foreign_server + SQL + + @connection.execute <<~SQL + CREATE FOREIGN TABLE foreign_professors ( + id int, + name character varying NOT NULL + ) SERVER foreign_server OPTIONS ( + table_name 'professors' + ) + SQL + end + + def teardown + disable_extension!("postgres_fdw", @connection) + @connection.execute <<~SQL + DROP SERVER IF EXISTS foreign_server CASCADE + SQL + end + + def test_table_exists + table_name = ForeignProfessor.table_name + assert_not ActiveRecord::Base.connection.table_exists?(table_name) + end + + def test_foreign_tables_are_valid_data_sources + table_name = ForeignProfessor.table_name + assert @connection.data_source_exists?(table_name), "'#{table_name}' should be a data source" + end + + def test_foreign_tables + assert_equal ["foreign_professors"], @connection.foreign_tables + end + + def test_foreign_table_exists + assert @connection.foreign_table_exists?("foreign_professors") + assert @connection.foreign_table_exists?(:foreign_professors) + assert_not @connection.foreign_table_exists?("nonexistingtable") + assert_not @connection.foreign_table_exists?("'") + assert_not @connection.foreign_table_exists?(nil) + end + + def test_attribute_names + assert_equal ["id", "name"], ForeignProfessor.attribute_names + end + + def test_attributes + professor = ForeignProfessorWithPk.find(@professor.id) + assert_equal @professor.attributes, professor.attributes + end + + def test_does_not_have_a_primary_key + assert_nil ForeignProfessor.primary_key + end + + def test_insert_record + # Explicit `id` here to avoid complex configurations to implicitly work with remote table + ForeignProfessorWithPk.create!(id: 100, name: "Leonardo") + + professor = ForeignProfessorWithPk.last + assert_equal "Leonardo", professor.name + end + + def test_update_record + professor = ForeignProfessorWithPk.find(@professor.id) + professor.name = "Albert" + professor.save! + professor.reload + assert_equal "Albert", professor.name + end + + def test_delete_record + professor = ForeignProfessorWithPk.find(@professor.id) + assert_difference("ForeignProfessor.count", -1) { professor.destroy } + end + 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..95dee3bf44 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/full_text_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlFullTextTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class Tsvector < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table("tsvectors") do |t| + t.tsvector "text_vector" + end + end + + teardown do + @connection.drop_table "tsvectors", if_exists: true + end + + def test_tsvector_column + column = Tsvector.columns_hash["text_vector"] + assert_equal :tsvector, column.type + assert_equal "tsvector", column.sql_type + assert_not_predicate column, :array? + + type = Tsvector.type_for_attribute("text_vector") + assert_not_predicate type, :binary? + end + + def test_update_tsvector + Tsvector.create text_vector: "'text' 'vector'" + tsvector = Tsvector.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 + + def test_schema_dump_with_shorthand + output = dump_table_schema("tsvectors") + assert_match %r{t\.tsvector "text_vector"}, output + 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..8c6f046553 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/geometric_test.rb @@ -0,0 +1,373 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" +require "support/schema_dumping_helper" + +class PostgresqlPointTest < ActiveRecord::PostgreSQLTestCase + include ConnectionHelper + include SchemaDumpingHelper + + class PostgresqlPoint < ActiveRecord::Base + attribute :x, :point + attribute :y, :point + attribute :z, :point + attribute :array_of_points, :point, array: true + attribute :legacy_x, :legacy_point + attribute :legacy_y, :legacy_point + attribute :legacy_z, :legacy_point + end + + def setup + @connection = ActiveRecord::Base.connection + @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)" + t.point :array_of_points, array: true + t.point :legacy_x + t.point :legacy_y, default: [12.2, 13.3] + t.point :legacy_z, default: "(14.4,15.5)" + end + end + + teardown do + @connection.drop_table "postgresql_points", if_exists: true + end + + def test_column + column = PostgresqlPoint.columns_hash["x"] + assert_equal :point, column.type + assert_equal "point", column.sql_type + assert_not_predicate column, :array? + + type = PostgresqlPoint.type_for_attribute("x") + assert_not_predicate type, :binary? + end + + def test_default + assert_equal ActiveRecord::Point.new(12.2, 13.3), PostgresqlPoint.column_defaults["y"] + assert_equal ActiveRecord::Point.new(12.2, 13.3), PostgresqlPoint.new.y + + assert_equal ActiveRecord::Point.new(14.4, 15.5), PostgresqlPoint.column_defaults["z"] + assert_equal ActiveRecord::Point.new(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 ActiveRecord::Point.new(10, 25.2), record.x + + record.x = ActiveRecord::Point.new(1.1, 2.2) + record.save! + assert record.reload + assert_equal ActiveRecord::Point.new(1.1, 2.2), record.x + end + + def test_mutation + p = PostgresqlPoint.create! x: ActiveRecord::Point.new(10, 20) + + p.x.y = 25 + p.save! + p.reload + + assert_equal ActiveRecord::Point.new(10.0, 25.0), p.x + assert_not_predicate p, :changed? + end + + def test_array_assignment + p = PostgresqlPoint.new(x: [1, 2]) + + assert_equal ActiveRecord::Point.new(1, 2), p.x + end + + def test_string_assignment + p = PostgresqlPoint.new(x: "(1, 2)") + + assert_equal ActiveRecord::Point.new(1, 2), p.x + end + + def test_empty_string_assignment + p = PostgresqlPoint.new(x: "") + assert_nil p.x + end + + def test_array_of_points_round_trip + expected_value = [ + ActiveRecord::Point.new(1, 2), + ActiveRecord::Point.new(2, 3), + ActiveRecord::Point.new(3, 4), + ] + p = PostgresqlPoint.new(array_of_points: expected_value) + + assert_equal expected_value, p.array_of_points + p.save! + p.reload + assert_equal expected_value, p.array_of_points + end + + def test_legacy_column + column = PostgresqlPoint.columns_hash["legacy_x"] + assert_equal :point, column.type + assert_equal "point", column.sql_type + assert_not_predicate column, :array? + + type = PostgresqlPoint.type_for_attribute("legacy_x") + assert_not_predicate type, :binary? + end + + def test_legacy_default + assert_equal [12.2, 13.3], PostgresqlPoint.column_defaults["legacy_y"] + assert_equal [12.2, 13.3], PostgresqlPoint.new.legacy_y + + assert_equal [14.4, 15.5], PostgresqlPoint.column_defaults["legacy_z"] + assert_equal [14.4, 15.5], PostgresqlPoint.new.legacy_z + end + + def test_legacy_schema_dumping + output = dump_table_schema("postgresql_points") + assert_match %r{t\.point\s+"legacy_x"$}, output + assert_match %r{t\.point\s+"legacy_y",\s+default: \[12\.2, 13\.3\]$}, output + assert_match %r{t\.point\s+"legacy_z",\s+default: \[14\.4, 15\.5\]$}, output + end + + def test_legacy_roundtrip + PostgresqlPoint.create! legacy_x: [10, 25.2] + record = PostgresqlPoint.first + assert_equal [10, 25.2], record.legacy_x + + record.legacy_x = [1.1, 2.2] + record.save! + assert record.reload + assert_equal [1.1, 2.2], record.legacy_x + end + + def test_legacy_mutation + p = PostgresqlPoint.create! legacy_x: [10, 20] + + p.legacy_x[1] = 25 + p.save! + p.reload + + assert_equal [10.0, 25.0], p.legacy_x + assert_not_predicate p, :changed? + end +end + +class PostgresqlGeometricTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + class PostgresqlGeometric < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table("postgresql_geometrics") do |t| + t.lseg :a_line_segment + t.box :a_box + t.path :a_path + t.polygon :a_polygon + t.circle :a_circle + end + end + + teardown do + @connection.drop_table "postgresql_geometrics", if_exists: true + end + + def test_geometric_types + g = PostgresqlGeometric.new( + 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)]", + a_polygon: "((2.0, 3), (5.5, 7.0), (8.5, 11.0))", + a_circle: "<(5.3, 10.4), 2>" + ) + + g.save! + + h = PostgresqlGeometric.find(g.id) + + 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 + + def test_alternative_format + g = PostgresqlGeometric.new( + 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))", + a_polygon: "2.0, 3, 5.5, 7.0, 8.5, 11.0", + a_circle: "((5.3, 10.4), 2)" + ) + + g.save! + + h = PostgresqlGeometric.find(g.id) + 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 + + def test_geometric_function + PostgresqlGeometric.create! a_path: "[(2.0, 3), (5.5, 7.0), (8.5, 11.0)]" # [ ] is an open path + PostgresqlGeometric.create! a_path: "((2.0, 3), (5.5, 7.0), (8.5, 11.0))" # ( ) is a closed path + + objs = PostgresqlGeometric.find_by_sql "SELECT isopen(a_path) FROM postgresql_geometrics ORDER BY id ASC" + assert_equal [true, false], objs.map(&:isopen) + + objs = PostgresqlGeometric.find_by_sql "SELECT isclosed(a_path) FROM postgresql_geometrics ORDER BY id ASC" + assert_equal [false, true], objs.map(&:isclosed) + end + + def test_schema_dumping + output = dump_table_schema("postgresql_geometrics") + assert_match %r{t\.lseg\s+"a_line_segment"$}, output + assert_match %r{t\.box\s+"a_box"$}, output + assert_match %r{t\.path\s+"a_path"$}, output + assert_match %r{t\.polygon\s+"a_polygon"$}, output + assert_match %r{t\.circle\s+"a_circle"$}, output + end +end + +class PostgreSQLGeometricLineTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + class PostgresqlLine < ActiveRecord::Base; end + + setup do + unless ActiveRecord::Base.connection.send(:postgresql_version) >= 90400 + skip("line type is not fully implemented") + end + @connection = ActiveRecord::Base.connection + @connection.create_table("postgresql_lines") do |t| + t.line :a_line + end + end + + teardown do + if defined?(@connection) + @connection.drop_table "postgresql_lines", if_exists: true + end + end + + def test_geometric_line_type + g = PostgresqlLine.new( + a_line: "{2.0, 3, 5.5}" + ) + g.save! + + h = PostgresqlLine.find(g.id) + assert_equal "{2,3,5.5}", h.a_line + end + + def test_alternative_format_line_type + g = PostgresqlLine.new( + a_line: "(2.0, 3), (4.0, 6.0)" + ) + g.save! + + h = PostgresqlLine.find(g.id) + assert_equal "{1.5,-1,0}", h.a_line + end + + def test_schema_dumping_for_line_type + output = dump_table_schema("postgresql_lines") + assert_match %r{t\.line\s+"a_line"$}, output + end +end + +class PostgreSQLGeometricTypesTest < ActiveRecord::PostgreSQLTestCase + attr_reader :connection, :table_name + + def setup + super + @connection = ActiveRecord::Base.connection + @table_name = :testings + end + + def test_creating_column_with_point_type + connection.create_table(table_name) do |t| + t.point :foo_point + end + + assert_column_exists(:foo_point) + assert_type_correct(:foo_point, :point) + end + + def test_creating_column_with_line_type + connection.create_table(table_name) do |t| + t.line :foo_line + end + + assert_column_exists(:foo_line) + assert_type_correct(:foo_line, :line) + end + + def test_creating_column_with_lseg_type + connection.create_table(table_name) do |t| + t.lseg :foo_lseg + end + + assert_column_exists(:foo_lseg) + assert_type_correct(:foo_lseg, :lseg) + end + + def test_creating_column_with_box_type + connection.create_table(table_name) do |t| + t.box :foo_box + end + + assert_column_exists(:foo_box) + assert_type_correct(:foo_box, :box) + end + + def test_creating_column_with_path_type + connection.create_table(table_name) do |t| + t.path :foo_path + end + + assert_column_exists(:foo_path) + assert_type_correct(:foo_path, :path) + end + + def test_creating_column_with_polygon_type + connection.create_table(table_name) do |t| + t.polygon :foo_polygon + end + + assert_column_exists(:foo_polygon) + assert_type_correct(:foo_polygon, :polygon) + end + + def test_creating_column_with_circle_type + connection.create_table(table_name) do |t| + t.circle :foo_circle + end + + assert_column_exists(:foo_circle) + assert_type_correct(:foo_circle, :circle) + end + + private + + def assert_column_exists(column_name) + assert connection.column_exists?(table_name, column_name) + end + + def assert_type_correct(column_name, type) + column = connection.columns(table_name).find { |c| c.name == column_name.to_s } + assert_equal type, column.type + 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..4b061a9375 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/hstore_test.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlHstoreTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class Hstore < ActiveRecord::Base + self.table_name = "hstores" + + store_accessor :settings, :language, :timezone + end + + class FakeParameters + def to_unsafe_h + { "hi" => "hi" } + end + end + + def setup + @connection = ActiveRecord::Base.connection + + enable_extension!("hstore", @connection) + + @connection.transaction do + @connection.create_table("hstores") do |t| + t.hstore "tags", default: "" + t.hstore "payload", array: true + t.hstore "settings" + end + end + Hstore.reset_column_information + @column = Hstore.columns_hash["tags"] + @type = Hstore.type_for_attribute("tags") + end + + teardown do + @connection.drop_table "hstores", if_exists: true + disable_extension!("hstore", @connection) + end + + def test_hstore_included_in_extensions + assert_respond_to @connection, :extensions + assert_includes @connection.extensions, "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_predicate @column, :array? + + assert_not_predicate @type, :binary? + 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::Current) 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" }, @type.deserialize("\"1\"=>\"2\"")) + assert_equal({}, @type.deserialize("")) + assert_equal({ "key" => nil }, @type.deserialize("key => NULL")) + assert_equal({ "c" => "}", '"a"' => 'b "a b' }, @type.deserialize(%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_predicate hstore, :changed? + end + + def test_dirty_from_user_equal + settings = { "alongkey" => "anything", "key" => "value" } + hstore = Hstore.create!(settings: settings) + + hstore.settings = { "key" => "value", "alongkey" => "anything" } + assert_equal settings, hstore.settings + assert_not_predicate hstore, :changed? + end + + def test_hstore_dirty_from_database_equal + settings = { "alongkey" => "anything", "key" => "value" } + hstore = Hstore.create!(settings: settings) + hstore.reload + + assert_equal settings, hstore.settings + hstore.settings = settings + assert_not_predicate hstore, :changed? + end + + def test_gen1 + assert_equal('" "=>""', @type.serialize(" " => "")) + end + + def test_gen2 + assert_equal('","=>""', @type.serialize("," => "")) + end + + def test_gen3 + assert_equal('"="=>""', @type.serialize("=" => "")) + end + + def test_gen4 + assert_equal('">"=>""', @type.serialize(">" => "")) + end + + def test_parse1 + assert_equal({ "a" => nil, "b" => nil, "c" => "NuLl", "null" => "c" }, @type.deserialize('a=>null,b=>NuLl,c=>"NuLl",null=>c')) + end + + def test_parse2 + assert_equal({ " " => " " }, @type.deserialize("\\ =>\\ ")) + end + + def test_parse3 + assert_equal({ "=" => ">" }, @type.deserialize("==>>")) + end + + def test_parse4 + assert_equal({ "=a" => "q=w" }, @type.deserialize('\=a=>q=w')) + end + + def test_parse5 + assert_equal({ "=a" => "q=w" }, @type.deserialize('"=a"=>q\=w')) + end + + def test_parse6 + assert_equal({ "\"a" => "q>w" }, @type.deserialize('"\"a"=>q>w')) + end + + def test_parse7 + assert_equal({ "\"a" => "q\"w" }, @type.deserialize('\"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 + + def test_schema_dump_with_shorthand + output = dump_table_schema("hstores") + assert_match %r[t\.hstore "tags",\s+default: {}], output + end + + def test_supports_to_unsafe_h_values + assert_equal("\"hi\"=>\"hi\"", @type.serialize(FakeParameters.new)) + 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..b1bf06d9e9 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/infinity_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlInfinityTest < ActiveRecord::PostgreSQLTestCase + include InTimeZone + + class PostgresqlInfinity < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:postgresql_infinities) do |t| + t.float :float + t.datetime :datetime + t.date :date + end + end + + teardown do + @connection.drop_table "postgresql_infinities", if_exists: true + 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 "type casting string on a float column" do + record = PostgresqlInfinity.new(float: "Infinity") + assert_equal Float::INFINITY, record.float + record = PostgresqlInfinity.new(float: "-Infinity") + assert_equal(-Float::INFINITY, record.float) + record = PostgresqlInfinity.new(float: "NaN") + assert record.float.nan?, "Expected #{record.float} to be NaN" + 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: "infinity") + record.reload + assert_equal Float::INFINITY, record.datetime + + record = PostgresqlInfinity.create!(datetime: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.datetime + end + + test "type casting infinity on a date column" do + record = PostgresqlInfinity.create!(date: "infinity") + record.reload + assert_equal Float::INFINITY, record.date + + record = PostgresqlInfinity.create!(date: Float::INFINITY) + record.reload + assert_equal Float::INFINITY, record.date + 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 + + test "assigning 'infinity' on a datetime column with TZ aware attributes" do + in_time_zone "Pacific Time (US & Canada)" do + record = PostgresqlInfinity.create!(datetime: "infinity") + assert_equal Float::INFINITY, record.datetime + assert_equal record.datetime, record.reload.datetime + end + ensure + # setting time_zone_aware_attributes causes the types to change. + # There is no way to do this automatically since it can be set on a superclass + PostgresqlInfinity.reset_column_information + end + + test "where clause with infinite range on a datetime column" do + record = PostgresqlInfinity.create!(datetime: Time.current) + + string = PostgresqlInfinity.where(datetime: "-infinity".."infinity") + assert_equal record, string.take + + infinity = PostgresqlInfinity.where(datetime: -::Float::INFINITY..::Float::INFINITY) + assert_equal record, infinity.take + + assert_equal infinity.to_sql, string.to_sql + end + + test "where clause with infinite range on a date column" do + record = PostgresqlInfinity.create!(date: Date.current) + + string = PostgresqlInfinity.where(date: "-infinity".."infinity") + assert_equal record, string.take + + infinity = PostgresqlInfinity.where(date: -::Float::INFINITY..::Float::INFINITY) + assert_equal record, infinity.take + + assert_equal infinity.to_sql, string.to_sql + end +end diff --git a/activerecord/test/cases/adapters/postgresql/integer_test.rb b/activerecord/test/cases/adapters/postgresql/integer_test.rb new file mode 100644 index 0000000000..3e45b057ff --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/integer_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_support/core_ext/numeric/bytes" + +class PostgresqlIntegerTest < ActiveRecord::PostgreSQLTestCase + class PgInteger < ActiveRecord::Base + end + + def setup + @connection = ActiveRecord::Base.connection + + @connection.transaction do + @connection.create_table "pg_integers", force: true do |t| + t.integer :quota, limit: 8, default: 2.gigabytes + end + end + end + + teardown do + @connection.drop_table "pg_integers", if_exists: true + end + + test "schema properly respects bigint ranges" do + assert_equal 2.gigabytes, PgInteger.new.quota + 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..ee08841eb3 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/json_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/json_shared_test_cases" + +module PostgresqlJSONSharedTestCases + include JSONSharedTestCases + + def setup + super + @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' + t.public_send column_type, "objects", array: true # t.json 'objects', array: true + end + rescue ActiveRecord::StatementInvalid + skip "do not test on PostgreSQL without #{column_type} type." + end + + def test_default + @connection.add_column "json_data_type", "permissions", column_type, default: { "users": "read", "posts": ["read", "write"] } + klass.reset_column_information + + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.column_defaults["permissions"]) + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.new.permissions) + end + + def test_deserialize_with_array + x = klass.new(objects: ["foo" => "bar"]) + assert_equal ["foo" => "bar"], x.objects + x.save! + assert_equal ["foo" => "bar"], x.objects + x.reload + assert_equal ["foo" => "bar"], x.objects + end +end + +class PostgresqlJSONTest < ActiveRecord::PostgreSQLTestCase + include PostgresqlJSONSharedTestCases + + def column_type + :json + end +end + +class PostgresqlJSONBTest < ActiveRecord::PostgreSQLTestCase + 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..8349ee6ee2 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/ltree_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlLtreeTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class Ltree < ActiveRecord::Base + self.table_name = "ltrees" + end + + def setup + @connection = ActiveRecord::Base.connection + + enable_extension!("ltree", @connection) + + @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.drop_table "ltrees", if_exists: true + end + + def test_column + column = Ltree.columns_hash["path"] + assert_equal :ltree, column.type + assert_equal "ltree", column.sql_type + assert_not_predicate column, :array? + + type = Ltree.type_for_attribute("path") + assert_not_predicate type, :binary? + 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 + + def test_schema_dump_with_shorthand + output = dump_table_schema("ltrees") + assert_match %r[t\.ltree "path"], output + 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..1aa0348879 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/money_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlMoneyTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + class PostgresqlMoney < ActiveRecord::Base + validates :depth, numericality: true + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.execute("set lc_monetary = 'C'") + @connection.create_table("postgresql_moneys", force: true) do |t| + t.money "wealth" + t.money "depth", default: "150.55" + end + end + + teardown do + @connection.drop_table "postgresql_moneys", if_exists: true + 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_not_predicate column, :array? + + type = PostgresqlMoney.type_for_attribute("wealth") + assert_not_predicate type, :binary? + end + + def test_default + assert_equal BigDecimal("150.55"), PostgresqlMoney.column_defaults["depth"] + assert_equal BigDecimal("150.55"), PostgresqlMoney.new.depth + assert_equal "150.55", PostgresqlMoney.new.depth_before_type_cast + 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 + type = PostgresqlMoney.type_for_attribute("wealth") + assert_equal(12345678.12, type.cast(+"$12,345,678.12")) + assert_equal(12345678.12, type.cast(+"$12.345.678,12")) + assert_equal(-1.15, type.cast(+"-$1.15")) + assert_equal(-2.25, type.cast(+"($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("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..736570451b --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/network_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlNetworkTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + class PostgresqlNetworkAddress < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table("postgresql_network_addresses", force: true) do |t| + t.inet "inet_address", default: "192.168.1.1" + t.cidr "cidr_address", default: "192.168.1.0/24" + t.macaddr "mac_address", default: "ff:ff:ff:ff:ff:ff" + end + end + + teardown do + @connection.drop_table "postgresql_network_addresses", if_exists: true + end + + def test_cidr_column + column = PostgresqlNetworkAddress.columns_hash["cidr_address"] + assert_equal :cidr, column.type + assert_equal "cidr", column.sql_type + assert_not_predicate column, :array? + + type = PostgresqlNetworkAddress.type_for_attribute("cidr_address") + assert_not_predicate type, :binary? + end + + def test_inet_column + column = PostgresqlNetworkAddress.columns_hash["inet_address"] + assert_equal :inet, column.type + assert_equal "inet", column.sql_type + assert_not_predicate column, :array? + + type = PostgresqlNetworkAddress.type_for_attribute("inet_address") + assert_not_predicate type, :binary? + end + + def test_macaddr_column + column = PostgresqlNetworkAddress.columns_hash["mac_address"] + assert_equal :macaddr, column.type + assert_equal "macaddr", column.sql_type + assert_not_predicate column, :array? + + type = PostgresqlNetworkAddress.type_for_attribute("mac_address") + assert_not_predicate type, :binary? + 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 + + def test_schema_dump_with_shorthand + output = dump_table_schema("postgresql_network_addresses") + assert_match %r{t\.inet\s+"inet_address",\s+default: "192\.168\.1\.1"}, output + assert_match %r{t\.cidr\s+"cidr_address",\s+default: "192\.168\.1\.0/24"}, output + assert_match %r{t\.macaddr\s+"mac_address",\s+default: "ff:ff:ff:ff:ff:ff"}, output + end +end diff --git a/activerecord/test/cases/adapters/postgresql/numbers_test.rb b/activerecord/test/cases/adapters/postgresql/numbers_test.rb new file mode 100644 index 0000000000..b53a12254d --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/numbers_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlNumberTest < ActiveRecord::PostgreSQLTestCase + class PostgresqlNumber < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table("postgresql_numbers", force: true) do |t| + t.column "single", "REAL" + t.column "double", "DOUBLE PRECISION" + end + end + + teardown do + @connection.drop_table "postgresql_numbers", if_exists: true + end + + def test_data_type + assert_equal :float, PostgresqlNumber.columns_hash["single"].type + assert_equal :float, PostgresqlNumber.columns_hash["double"].type + end + + def test_values + @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, second, third = PostgresqlNumber.find(1, 2, 3) + + assert_equal 123.456, first.single + assert_equal 123456.789, first.double + assert_equal(-::Float::INFINITY, second.single) + assert_equal ::Float::INFINITY, second.double + assert third.double.nan?, "Expected #{third.double} to be NaN" + end + + def test_update + record = PostgresqlNumber.create! single: "123.456", double: "123456.789" + new_single = 789.012 + new_double = 789012.345 + record.single = new_single + record.double = new_double + record.save! + + record.reload + assert_equal new_single, record.single + assert_equal new_double, record.double + end +end diff --git a/activerecord/test/cases/adapters/postgresql/partitions_test.rb b/activerecord/test/cases/adapters/postgresql/partitions_test.rb new file mode 100644 index 0000000000..0ac9ca1200 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/partitions_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgreSQLPartitionsTest < ActiveRecord::PostgreSQLTestCase + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table "partitioned_events", if_exists: true + end + + def test_partitions_table_exists + skip unless ActiveRecord::Base.connection.postgresql_version >= 100000 + @connection.create_table :partitioned_events, force: true, id: false, + options: "partition by range (issued_at)" do |t| + t.timestamp :issued_at + end + assert @connection.table_exists?("partitioned_events") + 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..34b4fc344b --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/postgresql_adapter_test.rb @@ -0,0 +1,446 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/ddl_helper" +require "support/connection_helper" + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapterTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + 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_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_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.to_i, 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.to_i, result.rows.first.first + end + + def test_exec_insert_default_values_with_returning_disabled_and_no_sequence_name_given + connection = connection_without_insert_returning + result = connection.exec_insert("insert into postgresql_partitioned_table_parent DEFAULT VALUES", nil, [], "id") + expect = connection.query("select max(id) from postgresql_partitioned_table_parent").first.first + assert_equal expect.to_i, result.rows.first.first + end + + def test_exec_insert_default_values_quoted_schema_with_returning_disabled_and_no_sequence_name_given + connection = connection_without_insert_returning + result = connection.exec_insert('insert into "public"."postgresql_partitioned_table_parent" DEFAULT VALUES', nil, [], "id") + expect = connection.query("select max(id) from postgresql_partitioned_table_parent").first.first + assert_equal expect.to_i, result.rows.first.first + 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 "public.accounts_id_seq", + @connection.default_sequence_name("accounts", "id") + + assert_equal "public.accounts_id_seq", + @connection.default_sequence_name("accounts") + end + + def test_default_sequence_name_bad_table + assert_equal "zomg_id_seq", + @connection.default_sequence_name("zomg", "id") + + assert_equal "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.to_s + 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.to_s + 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.drop_table "ex", if_exists: true + @connection.drop_table "ex2", if_exists: true + 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 + + if ActiveRecord::Base.connection.prepared_statements + def test_exec_with_binds + with_example_table do + string = @connection.quote("foo") + @connection.exec_query("INSERT INTO ex (id, data) VALUES (1, #{string})") + + bind = Relation::QueryAttribute.new("id", 1, Type::Value.new) + result = @connection.exec_query("SELECT id, data FROM ex WHERE id = $1", nil, [bind]) + + 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})") + + bind = Relation::QueryAttribute.new("id", "1-fuu", Type::Integer.new) + result = @connection.exec_query("SELECT id, data FROM ex WHERE id = $1", nil, [bind]) + + assert_equal 1, result.rows.length + assert_equal 2, result.columns.length + + assert_equal [[1, "foo"]], result.rows + end + end + 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_expression_index + with_example_table do + @connection.add_index "ex", "mod(id, 10), abs(number)", name: "expression" + index = @connection.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "mod(id, 10), abs(number)", index.columns + end + end + + def test_index_with_opclass + with_example_table do + @connection.add_index "ex", "data", opclass: "varchar_pattern_ops" + index = @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" } + assert_equal ["data"], index.columns + + @connection.remove_index "ex", "data" + assert_not @connection.indexes("ex").find { |idx| idx.name == "index_ex_on_data" } + 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.created_at AS alias_0, posts.id", + @connection.columns_for_distinct("posts.id", ["posts.created_at desc"]) + end + + def test_columns_for_distinct_few_orders + assert_equal "posts.created_at AS alias_0, posts.position AS alias_1, posts.id", + @connection.columns_for_distinct("posts.id", ["posts.created_at desc", "posts.position asc"]) + end + + def test_columns_for_distinct_with_case + assert_equal( + "CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END AS alias_0, posts.id", + @connection.columns_for_distinct("posts.id", + ["CASE WHEN author.is_active THEN UPPER(author.name) ELSE UPPER(author.email) END"]) + ) + end + + def test_columns_for_distinct_blank_not_nil_orders + assert_equal "posts.created_at AS alias_0, posts.id", + @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.created_at AS alias_0, posts.id", + @connection.columns_for_distinct("posts.id", [order]) + end + + def test_columns_for_distinct_with_nulls + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls first"]) + assert_equal "posts.updater_id AS alias_0, posts.title", @connection.columns_for_distinct("posts.title", ["posts.updater_id desc nulls last"]) + end + + def test_columns_for_distinct_without_order_specifiers + assert_equal "posts.updater_id AS alias_0, posts.title", + @connection.columns_for_distinct("posts.title", ["posts.updater_id"]) + + assert_equal "posts.updater_id AS alias_0, posts.title", + @connection.columns_for_distinct("posts.title", ["posts.updater_id nulls last"]) + + assert_equal "posts.updater_id AS alias_0, posts.title", + @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_unrecognized_type + reset_connection + connection = ActiveRecord::Base.connection + + silence_warnings do + assert_queries 2, ignore_none: true do + connection.select_all "select 'pg_catalog.pg_class'::regclass" + end + assert_queries 1, ignore_none: true do + connection.select_all "select 'pg_catalog.pg_class'::regclass" + 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_unrecognized_oid + reset_connection + connection = ActiveRecord::Base.connection + + warning = capture(:stderr) { + connection.select_all "select 'pg_catalog.pg_class'::regclass" + connection.select_all "select 'pg_catalog.pg_class'::regclass" + connection.select_all "select 'pg_catalog.pg_class'::regclass" + } + assert_match(/\Aunknown OID \d+: failed to recognize type of 'regclass'\. 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 + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + with_example_table do + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + end + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'") + end + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @connection.while_preventing_writes do + @connection.execute("DELETE FROM ex where data = '138853948594'") + end + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + with_example_table do + @connection.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @connection.while_preventing_writes do + assert_equal 1, @connection.execute("SELECT * FROM ex WHERE data = '138853948594'").entries.count + end + end + end + + def test_doesnt_error_when_a_show_query_is_called_while_preventing_writes + @connection.while_preventing_writes do + assert_equal 1, @connection.execute("SHOW TIME ZONE").entries.count + end + end + + def test_doesnt_error_when_a_set_query_is_called_while_preventing_writes + @connection.while_preventing_writes do + assert_equal [], @connection.execute("SET standard_conforming_strings = on").entries + end + end + + private + + 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/prepared_statements_disabled_test.rb b/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb new file mode 100644 index 0000000000..f7478b50c3 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/prepared_statements_disabled_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/computer" +require "models/developer" + +class PreparedStatementsDisabledTest < ActiveRecord::PostgreSQLTestCase + fixtures :developers + + def setup + @conn = ActiveRecord::Base.establish_connection :arunit_without_prepared_statements + end + + def teardown + @conn.release_connection + ActiveRecord::Base.establish_connection :arunit + end + + def test_select_query_works_even_when_prepared_statements_are_disabled + assert_not Developer.connection.prepared_statements + + david = developers(:david) + + assert_equal david, Developer.where(name: "David").last # With Binds + assert_operator Developer.count, :>, 0 # Without Binds + 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..d571355a9c --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/quoting_test.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter + class QuotingTest < ActiveRecord::PostgreSQLTestCase + def setup + @conn = ActiveRecord::Base.connection + end + + def test_type_cast_true + assert_equal true, @conn.type_cast(true) + end + + def test_type_cast_false + assert_equal false, @conn.type_cast(false) + end + + def test_quote_float_nan + nan = 0.0 / 0 + assert_equal "'NaN'", @conn.quote(nan) + end + + def test_quote_float_infinity + infinity = 1.0 / 0 + assert_equal "'Infinity'", @conn.quote(infinity) + end + + def test_quote_range + range = "1,2]'; SELECT * FROM users; --".."a" + type = OID::Range.new(Type::Integer.new, :int8range) + assert_equal "'[1,0]'", @conn.quote(type.serialize(range)) + end + + def test_quote_bit_string + value = "'); SELECT * FROM users; /*\n01\n*/--" + type = OID::Bit.new + assert_nil @conn.quote(type.serialize(value)) + end + + def test_quote_table_name_with_spaces + value = "user posts" + assert_equal "\"user posts\"", @conn.quote_table_name(value) + 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..478cd5aa76 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/range_test.rb @@ -0,0 +1,418 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class PostgresqlRange < ActiveRecord::Base + self.table_name = "postgresql_ranges" + self.time_zone_aware_types += [:tsrange, :tstzrange] +end + +class PostgresqlRangeTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + include ConnectionHelper + include InTimeZone + + 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.drop_table "postgresql_ranges", if_exists: true + @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("0.1")..BigDecimal("0.2"), @first_range.num_range + assert_equal BigDecimal("0.1")...BigDecimal("0.2"), @second_range.num_range + assert_equal BigDecimal("0.1")...BigDecimal("Infinity"), @third_range.num_range + assert_equal BigDecimal("-Infinity")...BigDecimal("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_timezone_awareness_tzrange + tz = "Pacific Time (US & Canada)" + + in_time_zone tz do + PostgresqlRange.reset_column_information + time_string = Time.current.to_s + time = Time.zone.parse(time_string) + + record = PostgresqlRange.new(tstz_range: time_string..time_string) + assert_equal time..time, record.tstz_range + assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone + + record.save! + record.reload + + assert_equal time..time, record.tstz_range + assert_equal ActiveSupport::TimeZone[tz], record.tstz_range.begin.time_zone + end + 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_timezone_awareness_tsrange + tz = "Pacific Time (US & Canada)" + + in_time_zone tz do + PostgresqlRange.reset_column_information + time_string = Time.current.to_s + time = Time.zone.parse(time_string) + + record = PostgresqlRange.new(ts_range: time_string..time_string) + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone + + record.save! + record.reload + + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone + end + end + + def test_create_tstzrange_preserve_usec + tstzrange = Time.parse("2010-01-01 14:30:00.670277 +0100")...Time.parse("2011-02-02 14:30:00.745125 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.670277 UTC")...Time.parse("2011-02-02 19:30:00.745125 UTC") + end + + def test_update_tstzrange_preserve_usec + assert_equal_round_trip(@first_range, :tstz_range, + Time.parse("2010-01-01 14:30:00.245124 CDT")...Time.parse("2011-02-02 14:30:00.451274 CET")) + assert_nil_round_trip(@first_range, :tstz_range, + Time.parse("2010-01-01 14:30:00.245124 +0100")...Time.parse("2010-01-01 13:30:00.245124 +0000")) + end + + def test_create_tsrange_preseve_usec + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@new_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0, 125435)...Time.send(tz, 2011, 2, 2, 14, 30, 0, 225435)) + end + + def test_update_tsrange_preserve_usec + tz = ::ActiveRecord::Base.default_timezone + assert_equal_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)...Time.send(tz, 2011, 2, 2, 14, 30, 0, 224242)) + assert_nil_round_trip(@first_range, :ts_range, + Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)...Time.send(tz, 2010, 1, 1, 14, 30, 0, 142432)) + end + + def test_timezone_awareness_tsrange_preserve_usec + tz = "Pacific Time (US & Canada)" + + in_time_zone tz do + PostgresqlRange.reset_column_information + time_string = "2017-09-26 07:30:59.132451 -0700" + time = Time.zone.parse(time_string) + assert time.usec > 0 + + record = PostgresqlRange.new(ts_range: time_string..time_string) + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone + assert_equal time.usec, record.ts_range.begin.usec + + record.save! + record.reload + + assert_equal time..time, record.ts_range + assert_equal ActiveSupport::TimeZone[tz], record.ts_range.begin.time_zone + assert_equal time.usec, record.ts_range.begin.usec + end + end + + def test_create_numrange + assert_equal_round_trip(@new_range, :num_range, + BigDecimal("0.5")...BigDecimal("1")) + end + + def test_update_numrange + assert_equal_round_trip(@first_range, :num_range, + BigDecimal("0.5")...BigDecimal("1")) + assert_nil_round_trip(@first_range, :num_range, + BigDecimal("0.5")...BigDecimal("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_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]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(int4_range: "(1, 10]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(int8_range: "(10, 100]") } + assert_raises(ArgumentError) { PostgresqlRange.create!(date_range: "(''2012-01-02'', ''2012-01-04'']") } + assert_raises(ArgumentError) { PostgresqlRange.create!(ts_range: "(''2010-01-01 14:30'', ''2011-01-01 14:30'']") } + assert_raises(ArgumentError) { PostgresqlRange.create!(tstz_range: "(''2010-01-01 14:30:00+05'', ''2011-01-01 14:30:00-03'']") } + end + + def test_where_by_attribute_with_range + range = 1..100 + record = PostgresqlRange.create!(int4_range: range) + assert_equal record, PostgresqlRange.where(int4_range: range).take + end + + def test_where_by_attribute_with_range_in_array + range = 1..100 + record = PostgresqlRange.create!(int4_range: range) + assert_equal record, PostgresqlRange.where(int4_range: [range]).take + 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 + + def test_infinity_values + PostgresqlRange.create!(int4_range: 1..Float::INFINITY, + int8_range: -Float::INFINITY..0, + float_range: -Float::INFINITY..Float::INFINITY) + + record = PostgresqlRange.first + + assert_equal(1...Float::INFINITY, record.int4_range) + assert_equal(-Float::INFINITY...1, record.int8_range) + assert_equal(-Float::INFINITY...Float::INFINITY, record.float_range) + 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 diff --git a/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb new file mode 100644 index 0000000000..ba477c63f4 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/referential_integrity_test.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class PostgreSQLReferentialIntegrityTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + + include ConnectionHelper + + IS_REFERENTIAL_INTEGRITY_SQL = lambda do |sql| + sql.match(/DISABLE TRIGGER ALL/) || sql.match(/ENABLE TRIGGER ALL/) + end + + module MissingSuperuserPrivileges + def execute(sql) + if IS_REFERENTIAL_INTEGRITY_SQL.call(sql) + super "BROKEN;" rescue nil # put transaction in broken state + raise ActiveRecord::StatementInvalid, "PG::InsufficientPrivilege" + else + super + end + end + end + + module ProgrammerMistake + def execute(sql) + if IS_REFERENTIAL_INTEGRITY_SQL.call(sql) + raise ArgumentError, "something is not right." + else + super + end + end + end + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + reset_connection + if ActiveRecord::Base.connection.is_a?(MissingSuperuserPrivileges) + raise "MissingSuperuserPrivileges patch was not removed" + end + end + + def test_should_reraise_invalid_foreign_key_exception_and_show_warning + @connection.extend MissingSuperuserPrivileges + + warning = capture(:stderr) do + e = assert_raises(ActiveRecord::InvalidForeignKey) do + @connection.disable_referential_integrity do + raise ActiveRecord::InvalidForeignKey, "Should be re-raised" + end + end + assert_equal "Should be re-raised", e.message + end + assert_match (/WARNING: Rails was not able to disable referential integrity/), warning + assert_match (/cause: PG::InsufficientPrivilege/), warning + end + + def test_does_not_print_warning_if_no_invalid_foreign_key_exception_was_raised + @connection.extend MissingSuperuserPrivileges + + warning = capture(:stderr) do + e = assert_raises(ActiveRecord::StatementInvalid) do + @connection.disable_referential_integrity do + raise ActiveRecord::StatementInvalid, "Should be re-raised" + end + end + assert_equal "Should be re-raised", e.message + end + assert warning.blank?, "expected no warnings but got:\n#{warning}" + end + + def test_does_not_break_transactions + @connection.extend MissingSuperuserPrivileges + + @connection.transaction do + @connection.disable_referential_integrity do + assert_transaction_is_not_broken + end + assert_transaction_is_not_broken + end + end + + def test_does_not_break_nested_transactions + @connection.extend MissingSuperuserPrivileges + + @connection.transaction do + @connection.transaction(requires_new: true) do + @connection.disable_referential_integrity do + assert_transaction_is_not_broken + end + end + assert_transaction_is_not_broken + end + end + + def test_only_catch_active_record_errors_others_bubble_up + @connection.extend ProgrammerMistake + + assert_raises ArgumentError do + @connection.disable_referential_integrity { } + end + end + + private + + def assert_transaction_is_not_broken + assert_equal 1, @connection.select_value("SELECT 1") + end +end diff --git a/activerecord/test/cases/adapters/postgresql/rename_table_test.rb b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb new file mode 100644 index 0000000000..7eccaf4aa2 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/rename_table_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlRenameTableTest < ActiveRecord::PostgreSQLTestCase + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :before_rename, force: true + end + + def teardown + @connection.drop_table "before_rename", if_exists: true + @connection.drop_table "after_rename", if_exists: true + end + + test "renaming a table also renames the primary key index" do + # sanity check + assert_equal 1, num_indices_named("before_rename_pkey") + assert_equal 0, num_indices_named("after_rename_pkey") + + @connection.rename_table :before_rename, :after_rename + + assert_equal 0, num_indices_named("before_rename_pkey") + assert_equal 1, num_indices_named("after_rename_pkey") + end + + private + + def num_indices_named(name) + @connection.execute(<<~SQL).values.length + SELECT 1 FROM "pg_index" + JOIN "pg_class" ON "pg_index"."indexrelid" = "pg_class"."oid" + WHERE "pg_class"."relname" = '#{name}' + SQL + 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..fcb0aec81b --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/schema_authorization_test.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "cases/helper" + +class SchemaThing < ActiveRecord::Base +end + +class SchemaAuthorizationTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = 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.drop_schema u + @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.select_value("SELECT name FROM #{TABLE_NAME} WHERE id = 1") + set_session_auth + end + end + end + + if ActiveRecord::Base.connection.prepared_statements + 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.select_value("SELECT name FROM #{TABLE_NAME} WHERE id = $1", "SQL", [bind_param(1)]) + set_session_auth + end + 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_not_includes @connection.tables, TABLE_NAME + USERS.each do |u| + set_session_auth u + assert_includes @connection.tables, TABLE_NAME + set_session_auth + end + end + + private + def set_session_auth(auth = nil) + @connection.session_auth = auth || "default" + end + + def bind_param(value) + ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new) + 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..336cec30ca --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/schema_test.rb @@ -0,0 +1,660 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/default" +require "support/schema_dumping_helper" + +module PGSchemaHelper + def with_schema_search_path(schema_search_path) + @connection.schema_search_path = schema_search_path + @connection.schema_cache.clear! + yield if block_given? + ensure + @connection.schema_search_path = "'$user', public" + @connection.schema_cache.clear! + end +end + +class SchemaTest < ActiveRecord::PostgreSQLTestCase + include PGSchemaHelper + self.use_transactional_tests = 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 = "(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 TABLE #{SCHEMA2_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.drop_schema SCHEMA2_NAME, if_exists: true + @connection.drop_schema SCHEMA_NAME, if_exists: true + end + + def test_schema_names + assert_equal ["public", "test_schema", "test_schema2"], @connection.schema_names + end + + def test_create_schema + @connection.create_schema "test_schema3" + assert @connection.schema_names.include? "test_schema3" + ensure + @connection.drop_schema "test_schema3" + end + + def test_raise_create_schema_with_existing_schema + @connection.create_schema "test_schema3" + assert_raises(ActiveRecord::StatementInvalid) do + @connection.create_schema "test_schema3" + end + ensure + @connection.drop_schema "test_schema3" + end + + def test_drop_schema + begin + @connection.create_schema "test_schema3" + ensure + @connection.drop_schema "test_schema3" + end + assert_not_includes @connection.schema_names, "test_schema3" + end + + def test_drop_schema_if_exists + @connection.create_schema "some_schema" + assert_includes @connection.schema_names, "some_schema" + @connection.drop_schema "some_schema", if_exists: true + assert_not_includes @connection.schema_names, "some_schema" + end + + def test_habtm_table_name_with_schema + ActiveRecord::Base.connection.drop_schema "music", if_exists: true + ActiveRecord::Base.connection.create_schema "music" + ActiveRecord::Base.connection.execute <<~SQL + 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.drop_schema "music", if_exists: true + end + + def test_drop_schema_with_nonexisting_schema + assert_raises(ActiveRecord::StatementInvalid) do + @connection.drop_schema "idontexist" + end + + assert_nothing_raised do + @connection.drop_schema "idontexist", if_exists: true + end + end + + def test_raise_wrapped_exception_on_bad_prepare + assert_raises(ActiveRecord::StatementInvalid) do + @connection.exec_query "select * from developers where id = ?", "sql", [bind_param(1)] + end + end + + if ActiveRecord::Base.connection.prepared_statements + def test_schema_change_with_prepared_stmt + altered = false + @connection.exec_query "select * from developers where id = $1", "sql", [bind_param(1)] + @connection.exec_query "alter table developers add column zomg int", "sql", [] + altered = true + @connection.exec_query "select * from developers where id = $1", "sql", [bind_param(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 + end + + def test_data_source_exists? + [Thing1, Thing2, Thing3, Thing4].each do |klass| + name = klass.table_name + assert @connection.data_source_exists?(name), "'#{name}' data_source should exist" + end + end + + def test_data_source_exists_when_on_schema_search_path + with_schema_search_path(SCHEMA_NAME) do + assert(@connection.data_source_exists?(TABLE_NAME), "data_source should exist and be found") + end + end + + def test_data_source_exists_when_not_on_schema_search_path + with_schema_search_path("PUBLIC") do + assert_not(@connection.data_source_exists?(TABLE_NAME), "data_source exists but should not be found") + end + end + + def test_data_source_exists_wrong_schema + assert_not(@connection.data_source_exists?("foo.things"), "data_source should not exist") + end + + def test_data_source_exists_quoted_names + [ %("#{SCHEMA_NAME}"."#{TABLE_NAME}"), %(#{SCHEMA_NAME}."#{TABLE_NAME}"), %(#{SCHEMA_NAME}."#{TABLE_NAME}")].each do |given| + assert(@connection.data_source_exists?(given), "data_source should exist when specified as #{given}") + end + with_schema_search_path(SCHEMA_NAME) do + given = %("#{TABLE_NAME}") + assert(@connection.data_source_exists?(given), "data_source should exist when specified as #{given}") + end + end + + def test_data_source_exists_quoted_table + with_schema_search_path(SCHEMA_NAME) do + assert(@connection.data_source_exists?('"things.table"'), "data_source 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) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_B_NAME) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_C_NAME) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_D_NAME) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_E_NAME) + assert @connection.index_name_exists?(TABLE_NAME, INDEX_E_NAME) + assert_not @connection.index_name_exists?(TABLE_NAME, "missing_index") + 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_dump_indexes_for_table_with_scheme_specified_in_name + indexes = @connection.indexes("#{SCHEMA_NAME}.#{TABLE_NAME}") + assert_equal 5, indexes.size + end + + def test_with_uppercase_index_name + @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", name: "things_Index" } + end + end + + def test_remove_index_when_schema_specified + @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)" + assert_nothing_raised { @connection.remove_index "things", name: "#{SCHEMA_NAME}.things_Index" } + + @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)" + assert_nothing_raised { @connection.remove_index "#{SCHEMA_NAME}.things", name: "things_Index" } + + @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)" + assert_nothing_raised { @connection.remove_index "#{SCHEMA_NAME}.things", name: "#{SCHEMA_NAME}.things_Index" } + + @connection.execute "CREATE INDEX \"things_Index\" ON #{SCHEMA_NAME}.things (name)" + assert_raises(ArgumentError) { @connection.remove_index "#{SCHEMA2_NAME}.things", name: "#{SCHEMA_NAME}.things_Index" } + 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}, #{SCHEMA2_NAME}") do + assert_equal "id", @connection.primary_key(PK_TABLE_NAME), "primary key should be found" + 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 + + def test_set_pk_sequence + table_name = "#{SCHEMA_NAME}.#{PK_TABLE_NAME}" + _, sequence_name = @connection.pk_and_sequence_for table_name + @connection.set_pk_sequence! table_name, 123 + assert_equal 124, @connection.select_value("SELECT nextval('#{sequence_name}')") + @connection.reset_pk_sequence! table_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 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(&:name) + assert_equal 5, indexes.size + + index_a, index_b, index_c, index_d, index_e = indexes + + do_dump_index_assertions_for_one_index(index_a, INDEX_A_NAME, first_index_column_name) + do_dump_index_assertions_for_one_index(index_b, INDEX_B_NAME, second_index_column_name) + do_dump_index_assertions_for_one_index(index_d, INDEX_D_NAME, third_index_column_name) + do_dump_index_assertions_for_one_index(index_e, INDEX_E_NAME, fourth_index_column_name) + + assert_equal :btree, index_a.using + assert_equal :btree, index_b.using + assert_equal :gin, index_c.using + assert_equal :btree, index_d.using + assert_equal :gin, index_e.using + + assert_equal :desc, index_d.orders + 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 + + def bind_param(value) + ActiveRecord::Relation::QueryAttribute.new(nil, value, ActiveRecord::Type::Value.new) + end +end + +class SchemaForeignKeyTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + end + + def test_dump_foreign_key_targeting_different_schema + @connection.create_schema "my_schema" + @connection.create_table "my_schema.trains" do |t| + t.string :name + end + @connection.create_table "wagons" do |t| + t.integer :train_id + end + @connection.add_foreign_key "wagons", "my_schema.trains", column: "train_id" + output = dump_table_schema "wagons" + assert_match %r{\s+add_foreign_key "wagons", "my_schema\.trains", column: "train_id"$}, output + ensure + @connection.drop_table "wagons", if_exists: true + @connection.drop_table "my_schema.trains", if_exists: true + @connection.drop_schema "my_schema", if_exists: true + end +end + +class SchemaIndexOpclassTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "trains" do |t| + t.string :name + t.string :position + t.text :description + end + end + + teardown do + @connection.drop_table "trains", if_exists: true + end + + def test_string_opclass_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name text_pattern_ops, description text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: :text_pattern_ops/, output) + end + + def test_non_default_opclass_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name, description text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: \{ description: :text_pattern_ops \}/, output) + end + + def test_opclass_class_parsing_on_non_reserved_and_cannot_be_function_or_type_keyword + @connection.enable_extension("pg_trgm") + @connection.execute "CREATE INDEX trains_position ON trains USING gin(position gin_trgm_ops)" + @connection.execute "CREATE INDEX trains_name_and_position ON trains USING btree(name, position text_pattern_ops)" + + output = dump_table_schema "trains" + + assert_match(/opclass: :gin_trgm_ops/, output) + assert_match(/opclass: \{ position: :text_pattern_ops \}/, output) + end +end + +class SchemaIndexNullsOrderTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "trains" do |t| + t.string :name + t.text :description + end + end + + teardown do + @connection.drop_table "trains", if_exists: true + end + + def test_nulls_order_is_dumped + @connection.execute "CREATE INDEX trains_name_and_description ON trains USING btree(name NULLS FIRST, description)" + output = dump_table_schema "trains" + assert_match(/order: \{ name: "NULLS FIRST" \}/, output) + end + + def test_non_default_order_with_nulls_is_dumped + @connection.execute "CREATE INDEX trains_name_and_desc ON trains USING btree(name DESC NULLS LAST, description)" + output = dump_table_schema "trains" + assert_match(/order: \{ name: "DESC NULLS LAST" \}/, output) + end +end + +class DefaultsUsingMultipleSchemasAndDomainTest < ActiveRecord::PostgreSQLTestCase + setup do + @connection = ActiveRecord::Base.connection + @connection.drop_schema "schema_1", if_exists: true + @connection.execute "CREATE SCHEMA schema_1" + @connection.execute "CREATE DOMAIN schema_1.text AS text" + @connection.execute "CREATE DOMAIN schema_1.varchar AS varchar" + @connection.execute "CREATE DOMAIN schema_1.bpchar AS bpchar" + + @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" + t.decimal "decimal_col", default: "3.14159265358979323846" + end + Default.reset_column_information + end + + teardown do + @connection.schema_search_path = @old_search_path + @connection.drop_schema "schema_1", if_exists: true + 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 parsed" + 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 parsed" + end + + def test_decimal_defaults_in_new_schema_when_overriding_domain + assert_equal BigDecimal("3.14159265358979323846"), Default.new.decimal_col, "Default of decimal column was not correctly parsed" + 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 parsed" + 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 parsed 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 +end + +class SchemaWithDotsTest < ActiveRecord::PostgreSQLTestCase + include PGSchemaHelper + self.use_transactional_tests = false + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_schema "my.schema" + end + + teardown do + @connection.drop_schema "my.schema", if_exists: true + end + + test "rename_table" do + with_schema_search_path('"my.schema"') do + @connection.create_table :posts + @connection.rename_table :posts, :articles + assert_equal ["articles"], @connection.tables + end + end + + test "Active Record basics" do + with_schema_search_path('"my.schema"') do + @connection.create_table :articles do |t| + t.string :title + end + article_class = Class.new(ActiveRecord::Base) do + self.table_name = '"my.schema".articles' + end + + article_class.create!(title: "zOMG, welcome to my blorgh!") + welcome_article = article_class.last + assert_equal "zOMG, welcome to my blorgh!", welcome_article.title + end + end +end diff --git a/activerecord/test/cases/adapters/postgresql/serial_test.rb b/activerecord/test/cases/adapters/postgresql/serial_test.rb new file mode 100644 index 0000000000..83ea86be6d --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/serial_test.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlSerialTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + class PostgresqlSerial < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "postgresql_serials", force: true do |t| + t.serial :seq + t.integer :serials_id, default: -> { "nextval('postgresql_serials_id_seq')" } + end + end + + teardown do + @connection.drop_table "postgresql_serials", if_exists: true + end + + def test_serial_column + column = PostgresqlSerial.columns_hash["seq"] + assert_equal :integer, column.type + assert_equal "integer", column.sql_type + assert_predicate column, :serial? + end + + def test_not_serial_column + column = PostgresqlSerial.columns_hash["serials_id"] + assert_equal :integer, column.type + assert_equal "integer", column.sql_type + assert_not_predicate column, :serial? + end + + def test_schema_dump_with_shorthand + output = dump_table_schema "postgresql_serials" + assert_match %r{t\.serial\s+"seq",\s+null: false$}, output + end + + def test_schema_dump_with_not_serial + output = dump_table_schema "postgresql_serials" + assert_match %r{t\.integer\s+"serials_id",\s+default: -> \{ "nextval\('postgresql_serials_id_seq'::regclass\)" \}$}, output + end +end + +class PostgresqlBigSerialTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + class PostgresqlBigSerial < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "postgresql_big_serials", force: true do |t| + t.bigserial :seq + t.bigint :serials_id, default: -> { "nextval('postgresql_big_serials_id_seq')" } + end + end + + teardown do + @connection.drop_table "postgresql_big_serials", if_exists: true + end + + def test_bigserial_column + column = PostgresqlBigSerial.columns_hash["seq"] + assert_equal :integer, column.type + assert_equal "bigint", column.sql_type + assert_predicate column, :serial? + end + + def test_not_bigserial_column + column = PostgresqlBigSerial.columns_hash["serials_id"] + assert_equal :integer, column.type + assert_equal "bigint", column.sql_type + assert_not_predicate column, :serial? + end + + def test_schema_dump_with_shorthand + output = dump_table_schema "postgresql_big_serials" + assert_match %r{t\.bigserial\s+"seq",\s+null: false$}, output + end + + def test_schema_dump_with_not_bigserial + output = dump_table_schema "postgresql_big_serials" + assert_match %r{t\.bigint\s+"serials_id",\s+default: -> \{ "nextval\('postgresql_big_serials_id_seq'::regclass\)" \}$}, output + end +end + +module SequenceNameDetectionTestCases + class CollidedSequenceNameTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :foo_bar, force: true do |t| + t.serial :baz_id + end + @connection.create_table :foo, force: true do |t| + t.serial :bar_id + t.bigserial :bar_baz_id + end + end + + def teardown + @connection.drop_table :foo_bar, if_exists: true + @connection.drop_table :foo, if_exists: true + end + + def test_serial_columns + columns = @connection.columns(:foo) + columns.each do |column| + assert_equal :integer, column.type + assert_predicate column, :serial? + end + end + + def test_schema_dump_with_collided_sequence_name + output = dump_table_schema "foo" + assert_match %r{t\.serial\s+"bar_id",\s+null: false$}, output + assert_match %r{t\.bigserial\s+"bar_baz_id",\s+null: false$}, output + end + end + + class LongerSequenceNameDetectionTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + + def setup + @table_name = "long_table_name_to_test_sequence_name_detection_for_serial_cols" + @connection = ActiveRecord::Base.connection + @connection.create_table @table_name, force: true do |t| + t.serial :seq + t.bigserial :bigseq + end + end + + def teardown + @connection.drop_table @table_name, if_exists: true + end + + def test_serial_columns + columns = @connection.columns(@table_name) + columns.each do |column| + assert_equal :integer, column.type + assert_predicate column, :serial? + end + end + + def test_schema_dump_with_long_table_name + output = dump_table_schema @table_name + assert_match %r{create_table "#{@table_name}", force: :cascade}, output + assert_match %r{t\.serial\s+"seq",\s+null: false$}, output + assert_match %r{t\.bigserial\s+"bigseq",\s+null: false$}, output + end + 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..fef4b02b04 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/statement_pool_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class PostgreSQLAdapter < AbstractAdapter + class InactivePgConnection + def query(*args) + raise PG::Error + end + + def status + PG::CONNECTION_BAD + end + end + + class StatementPoolTest < ActiveRecord::PostgreSQLTestCase + 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 InactivePgConnection.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..b7f213efc8 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/timestamp_test.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/topic" + +class PostgresqlTimestampTest < ActiveRecord::PostgreSQLTestCase + class PostgresqlTimestampWithZone < ActiveRecord::Base; end + + self.use_transactional_tests = 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 PostgresqlTimestampFixtureTest < ActiveRecord::PostgreSQLTestCase + 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_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 +end diff --git a/activerecord/test/cases/adapters/postgresql/transaction_test.rb b/activerecord/test/cases/adapters/postgresql/transaction_test.rb new file mode 100644 index 0000000000..919ff3d158 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/transaction_test.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" +require "concurrent/atomic/cyclic_barrier" + +module ActiveRecord + class PostgresqlTransactionTest < ActiveRecord::PostgreSQLTestCase + self.use_transactional_tests = false + + class Sample < ActiveRecord::Base + self.table_name = "samples" + end + + setup do + @abort, Thread.abort_on_exception = Thread.abort_on_exception, false + Thread.report_on_exception, @original_report_on_exception = false, Thread.report_on_exception + + @connection = ActiveRecord::Base.connection + + @connection.transaction do + @connection.drop_table "samples", if_exists: true + @connection.create_table("samples") do |t| + t.integer "value" + end + end + + Sample.reset_column_information + end + + teardown do + @connection.drop_table "samples", if_exists: true + + Thread.abort_on_exception = @abort + Thread.report_on_exception = @original_report_on_exception + end + + test "raises SerializationFailure when a serialization failure occurs" do + assert_raises(ActiveRecord::SerializationFailure) do + before = Concurrent::CyclicBarrier.new(2) + after = Concurrent::CyclicBarrier.new(2) + + thread = Thread.new do + with_warning_suppression do + Sample.transaction isolation: :serializable do + before.wait + Sample.create value: Sample.sum(:value) + after.wait + end + end + end + + begin + with_warning_suppression do + Sample.transaction isolation: :serializable do + before.wait + Sample.create value: Sample.sum(:value) + after.wait + end + end + ensure + thread.join + end + end + end + + test "raises Deadlocked when a deadlock is encountered" do + with_warning_suppression do + assert_raises(ActiveRecord::Deadlocked) do + barrier = Concurrent::CyclicBarrier.new(2) + + s1 = Sample.create value: 1 + s2 = Sample.create value: 2 + + thread = Thread.new do + Sample.transaction do + s1.lock! + barrier.wait + s2.update value: 1 + end + end + + begin + Sample.transaction do + s2.lock! + barrier.wait + s1.update value: 2 + end + ensure + thread.join + end + end + end + end + + test "raises LockWaitTimeout when lock wait timeout exceeded" do + assert_raises(ActiveRecord::LockWaitTimeout) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET lock_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET lock_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when statement timeout exceeded" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch1 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch1.count_down + latch2.wait + end + end + + begin + Sample.transaction do + latch1.wait + Sample.connection.execute("SET statement_timeout = 1") + Sample.lock.find(s.id) + end + ensure + Sample.connection.execute("SET statement_timeout = DEFAULT") + latch2.count_down + thread.join + end + end + end + + test "raises QueryCanceled when canceling statement due to user request" do + assert_raises(ActiveRecord::QueryCanceled) do + s = Sample.create!(value: 1) + latch = Concurrent::CountDownLatch.new + + thread = Thread.new do + Sample.transaction do + Sample.lock.find(s.id) + latch.count_down + sleep(0.5) + conn = Sample.connection + pid = conn.query_value("SELECT pid FROM pg_stat_activity WHERE query LIKE '% FOR UPDATE'") + conn.execute("SELECT pg_cancel_backend(#{pid})") + end + end + + begin + Sample.transaction do + latch.wait + Sample.lock.find(s.id) + end + ensure + thread.join + end + end + end + + private + + def with_warning_suppression + log_level = ActiveRecord::Base.connection.client_min_messages + ActiveRecord::Base.connection.client_min_messages = "error" + yield + ensure + ActiveRecord::Base.connection.client_min_messages = log_level + end + 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..8212ed4263 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/type_lookup_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "cases/helper" + +class PostgresqlTypeLookupTest < ActiveRecord::PostgreSQLTestCase + setup do + @connection = ActiveRecord::Base.connection + end + + test "array delimiters are looked up correctly" do + box_array = @connection.send(:type_map).lookup(1020) + int_array = @connection.send(:type_map).lookup(1007) + + assert_equal ";", box_array.delimiter + assert_equal ",", int_array.delimiter + end + + test "array types correctly respect registration of subtypes" do + int_array = @connection.send(:type_map).lookup(1007, -1, "integer[]") + bigint_array = @connection.send(:type_map).lookup(1016, -1, "bigint[]") + big_array = [123456789123456789] + + assert_raises(ActiveModel::RangeError) { int_array.serialize(big_array) } + assert_equal "{123456789123456789}", @connection.type_cast(bigint_array.serialize(big_array)) + end + + test "range types correctly respect registration of subtypes" do + int_range = @connection.send(:type_map).lookup(3904, -1, "int4range") + bigint_range = @connection.send(:type_map).lookup(3926, -1, "int8range") + big_range = 0..123456789123456789 + + assert_raises(ActiveModel::RangeError) { int_range.serialize(big_range) } + assert_equal "[0,123456789123456789]", @connection.type_cast(bigint_range.serialize(big_range)) + 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..c91884f384 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/utils_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_record/connection_adapters/postgresql/utils" + +class PostgreSQLUtilsTest < ActiveRecord::PostgreSQLTestCase + 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 < ActiveRecord::PostgreSQLTestCase + 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_nil hash[Name.new("schema", "articles")] + assert_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..9912763c1b --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/uuid_test.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +module PostgresqlUUIDHelper + def connection + @connection ||= ActiveRecord::Base.connection + end + + def drop_table(name) + connection.drop_table name, if_exists: true + end + + def uuid_function + connection.supports_pgcrypto_uuid? ? "gen_random_uuid()" : "uuid_generate_v4()" + end + + def uuid_default + connection.supports_pgcrypto_uuid? ? {} : { default: uuid_function } + end +end + +class PostgresqlUUIDTest < ActiveRecord::PostgreSQLTestCase + include PostgresqlUUIDHelper + include SchemaDumpingHelper + + class UUIDType < ActiveRecord::Base + self.table_name = "uuid_data_type" + end + + setup do + enable_extension!("uuid-ossp", connection) + enable_extension!("pgcrypto", connection) if connection.supports_pgcrypto_uuid? + + connection.create_table "uuid_data_type" do |t| + t.uuid "guid" + end + end + + teardown do + drop_table "uuid_data_type" + end + + if ActiveRecord::Base.connection.respond_to?(:supports_pgcrypto_uuid?) && + ActiveRecord::Base.connection.supports_pgcrypto_uuid? + def test_uuid_column_default + connection.add_column :uuid_data_type, :thingy, :uuid, null: false, default: "gen_random_uuid()" + UUIDType.reset_column_information + column = UUIDType.columns_hash["thingy"] + assert_equal "gen_random_uuid()", column.default_function + end + 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_add_column_with_null_true_and_default_nil + connection.add_column :uuid_data_type, :thingy, :uuid, null: true, default: nil + + UUIDType.reset_column_information + column = UUIDType.columns_hash["thingy"] + + assert column.null + assert_nil column.default + end + + def test_add_column_with_default_array + connection.add_column :uuid_data_type, :thingy, :uuid, array: true, default: [] + + UUIDType.reset_column_information + column = UUIDType.columns_hash["thingy"] + + assert_predicate column, :array? + assert_equal "{}", column.default + + schema = dump_table_schema "uuid_data_type" + assert_match %r{t\.uuid "thingy", default: \[\], array: true$}, schema + 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_predicate column, :array? + + type = UUIDType.type_for_attribute("guid") + assert_not_predicate type, :binary? + end + + def test_treat_blank_uuid_as_nil + UUIDType.create! guid: "" + assert_nil(UUIDType.last.guid) + end + + def test_treat_invalid_uuid_as_nil + uuid = UUIDType.create! guid: "foobar" + assert_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_acceptable_uuid_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}", + # The following is not a valid RFC 4122 UUID, but PG doesn't seem to care, + # so we shouldn't block it either. (Pay attention to "fb6d" – the "f" here + # is invalid – it must be one of 8, 9, A, B, a, b according to the spec.) + "{a0eebc99-9c0b-4ef8-fb6d-6bb9bd380a11}", + ].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", + "a0eebc999r0b4ef8ab6d6bb9bd380a11", + "a0ee-bc99------4ef8-bb6d-6bb9-bd38-0a11", + "{a0eebc99-bb6d6bb9-bd380a11}", + "{a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11", + "a0eebc99-9c0b4ef8-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 + + def test_schema_dump_with_shorthand + output = dump_table_schema "uuid_data_type" + assert_match %r{t\.uuid "guid"}, output + end + + def test_uniqueness_validation_ignores_uuid + klass = Class.new(ActiveRecord::Base) do + self.table_name = "uuid_data_type" + validates :guid, uniqueness: { case_sensitive: false } + + def self.name + "UUIDType" + end + end + + record = klass.create!(guid: "a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11") + duplicate = klass.new(guid: record.guid) + + assert record.guid.present? # Ensure we actually are testing a UUID + assert_not_predicate duplicate, :valid? + end +end + +class PostgresqlUUIDGenerationTest < ActiveRecord::PostgreSQLTestCase + include PostgresqlUUIDHelper + include SchemaDumpingHelper + + class UUID < ActiveRecord::Base + self.table_name = "pg_uuids" + end + + setup do + 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_function} $$ + 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 + + connection.create_table("pg_uuids_3", id: :uuid, **uuid_default) do |t| + t.string "name" + end + end + + teardown do + drop_table "pg_uuids" + drop_table "pg_uuids_2" + drop_table "pg_uuids_3" + connection.execute "DROP FUNCTION IF EXISTS my_uuid_generator();" + end + + 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_nil seq + end + + def test_schema_dumper_for_uuid_primary_key + schema = dump_table_schema "pg_uuids" + assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: -> { "uuid_generate_v1\(\)" }/, schema) + assert_match(/t\.uuid "other_uuid", default: -> { "uuid_generate_v4\(\)" }/, schema) + end + + def test_schema_dumper_for_uuid_primary_key_with_custom_default + schema = dump_table_schema "pg_uuids_2" + assert_match(/\bcreate_table "pg_uuids_2", id: :uuid, default: -> { "my_uuid_generator\(\)" }/, schema) + assert_match(/t\.uuid "other_uuid_2", default: -> { "my_uuid_generator\(\)" }/, schema) + end + + def test_schema_dumper_for_uuid_primary_key_default + schema = dump_table_schema "pg_uuids_3" + if connection.supports_pgcrypto_uuid? + assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "gen_random_uuid\(\)" }/, schema) + else + assert_match(/\bcreate_table "pg_uuids_3", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema) + end + end + + def test_schema_dumper_for_uuid_primary_key_default_in_legacy_migration + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + migration = Class.new(ActiveRecord::Migration[5.0]) do + def version; 101 end + def migrate(x) + create_table("pg_uuids_4", id: :uuid) + end + end.new + ActiveRecord::Migrator.new(:up, [migration]).migrate + + schema = dump_table_schema "pg_uuids_4" + assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: -> { "uuid_generate_v4\(\)" }/, schema) + ensure + drop_table "pg_uuids_4" + ActiveRecord::Migration.verbose = @verbose_was + end +end + +class PostgresqlUUIDTestNilDefault < ActiveRecord::PostgreSQLTestCase + include PostgresqlUUIDHelper + include SchemaDumpingHelper + + setup do + 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 + + 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 + + def test_schema_dumper_for_uuid_primary_key_with_default_override_via_nil + schema = dump_table_schema "pg_uuids" + assert_match(/\bcreate_table "pg_uuids", id: :uuid, default: nil/, schema) + end + + def test_schema_dumper_for_uuid_primary_key_with_default_nil_in_legacy_migration + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + migration = Class.new(ActiveRecord::Migration[5.0]) do + def version; 101 end + def migrate(x) + create_table("pg_uuids_4", id: :uuid, default: nil) + end + end.new + ActiveRecord::Migrator.new(:up, [migration]).migrate + + schema = dump_table_schema "pg_uuids_4" + assert_match(/\bcreate_table "pg_uuids_4", id: :uuid, default: nil/, schema) + ensure + drop_table "pg_uuids_4" + ActiveRecord::Migration.verbose = @verbose_was + end +end + +class PostgresqlUUIDTestInverseOf < ActiveRecord::PostgreSQLTestCase + 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 + connection.transaction do + connection.create_table("pg_uuid_posts", id: :uuid, **uuid_default) do |t| + t.string "title" + end + connection.create_table("pg_uuid_comments", id: :uuid, **uuid_default) do |t| + t.references :uuid_post, type: :uuid + t.string "content" + end + end + end + + teardown do + drop_table "pg_uuid_comments" + drop_table "pg_uuid_posts" + end + + def test_collection_association_with_uuid + post = UuidPost.create! + comment = post.uuid_comments.create! + assert post.uuid_comments.find(comment.id) + end + + def test_find_with_uuid + UuidPost.create! + assert_raise ActiveRecord::RecordNotFound do + UuidPost.find(123456) + end + end + + def test_find_by_with_uuid + UuidPost.create! + assert_nil UuidPost.find_by(id: 789) + 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..71ead6f7f3 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/xml_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class PostgresqlXMLTest < ActiveRecord::PostgreSQLTestCase + include SchemaDumpingHelper + 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.drop_table "xml_data_type", if_exists: true + end + + def test_column + assert_equal :xml, @column.type + end + + def test_null_xml + @connection.execute "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 + + def test_schema_dump_with_shorthand + output = dump_table_schema("xml_data_type") + assert_match %r{t\.xml "payload"}, output + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb new file mode 100644 index 0000000000..93a7dafebd --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/bind_parameter_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +module ActiveRecord + module ConnectionAdapters + class SQLite3Adapter + class BindParameterTest < ActiveRecord::SQLite3TestCase + def test_too_many_binds + topics = Topic.where(id: (1..999).to_a << 2**63) + assert_equal Topic.count, topics.count + + topics = Topic.where.not(id: (1..999).to_a << 2**63) + assert_equal 0, topics.count + end + end + end + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/collation_test.rb b/activerecord/test/cases/adapters/sqlite3/collation_test.rb new file mode 100644 index 0000000000..76c8f7d8dd --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/collation_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class SQLite3CollationTest < ActiveRecord::SQLite3TestCase + include SchemaDumpingHelper + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :collation_table_sqlite3, force: true do |t| + t.string :string_nocase, collation: "NOCASE" + t.text :text_rtrim, collation: "RTRIM" + end + end + + def teardown + @connection.drop_table :collation_table_sqlite3, if_exists: true + end + + test "string column with collation" do + column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "string_nocase" } + assert_equal :string, column.type + assert_equal "NOCASE", column.collation + end + + test "text column with collation" do + column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "text_rtrim" } + assert_equal :text, column.type + assert_equal "RTRIM", column.collation + end + + test "add column with collation" do + @connection.add_column :collation_table_sqlite3, :title, :string, collation: "RTRIM" + + column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "title" } + assert_equal :string, column.type + assert_equal "RTRIM", column.collation + end + + test "change column with collation" do + @connection.add_column :collation_table_sqlite3, :description, :string + @connection.change_column :collation_table_sqlite3, :description, :text, collation: "RTRIM" + + column = @connection.columns(:collation_table_sqlite3).find { |c| c.name == "description" } + assert_equal :text, column.type + assert_equal "RTRIM", column.collation + end + + test "schema dump includes collation" do + output = dump_table_schema("collation_table_sqlite3") + assert_match %r{t\.string\s+"string_nocase",\s+collation: "NOCASE"$}, output + assert_match %r{t\.text\s+"text_rtrim",\s+collation: "RTRIM"$}, output + 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..ffb1d6afce --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/copy_table_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "cases/helper" + +class CopyTableTest < ActiveRecord::SQLite3TestCase + 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_nil table_indexes_without_name("comments_with_index") + assert_nil 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_nil original_id.limit + assert_nil 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 + +private + 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..b6d2ccdb53 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/explain_test.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/post" + +class SQLite3ExplainTest < ActiveRecord::SQLite3TestCase + fixtures :authors + + def test_explain_for_one_query + explain = Author.where(id: 1).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain + assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain) + end + + def test_explain_with_eager_loading + explain = Author.where(id: 1).includes(:posts).explain + assert_match %r(EXPLAIN for: SELECT "authors"\.\* FROM "authors" WHERE "authors"\."id" = (?:\? \[\["id", 1\]\]|1)), explain + assert_match(/(SEARCH )?TABLE authors USING (INTEGER )?PRIMARY KEY/, explain) + assert_match %r(EXPLAIN for: SELECT "posts"\.\* FROM "posts" WHERE "posts"\."author_id" = (?:\? \[\["author_id", 1\]\]|1)), explain + assert_match(/(SCAN )?TABLE posts/, explain) + end +end diff --git a/activerecord/test/cases/adapters/sqlite3/json_test.rb b/activerecord/test/cases/adapters/sqlite3/json_test.rb new file mode 100644 index 0000000000..6f247fcd22 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/json_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/json_shared_test_cases" + +class SQLite3JSONTest < ActiveRecord::SQLite3TestCase + include JSONSharedTestCases + + def setup + super + @connection.create_table("json_data_type") do |t| + t.json "payload", default: {} + t.json "settings" + end + end + + def test_default + @connection.add_column "json_data_type", "permissions", column_type, default: { "users": "read", "posts": ["read", "write"] } + klass.reset_column_information + + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.column_defaults["permissions"]) + assert_equal({ "users" => "read", "posts" => ["read", "write"] }, klass.new.permissions) + end + + private + def column_type + :json + 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..40b58e86bf --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/quoting_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "cases/helper" +require "bigdecimal" +require "securerandom" + +class SQLite3QuotingTest < ActiveRecord::SQLite3TestCase + def setup + @conn = ActiveRecord::Base.connection + @initial_represent_boolean_as_integer = ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer + end + + def teardown + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = @initial_represent_boolean_as_integer + end + + def test_type_cast_binary_encoding_without_logger + @conn.extend(Module.new { def logger; end }) + binary = SecureRandom.hex + expected = binary.dup.encode!(Encoding::UTF_8) + assert_equal expected, @conn.type_cast(binary) + end + + def test_type_cast_true + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = false + assert_equal "t", @conn.type_cast(true) + + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true + assert_equal 1, @conn.type_cast(true) + end + + def test_type_cast_false + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = false + assert_equal "f", @conn.type_cast(false) + + ActiveRecord::ConnectionAdapters::SQLite3Adapter.represent_boolean_as_integer = true + assert_equal 0, @conn.type_cast(false) + end + + def test_type_cast_bigdecimal + bd = BigDecimal "10.0" + assert_equal bd.to_f, @conn.type_cast(bd) + end + + def test_quoting_binary_strings + value = "hello".encode("ascii-8bit") + type = ActiveRecord::Type::String.new + + assert_equal "'hello'", @conn.quote(type.serialize(value)) + end + + def test_quoted_time_returns_date_qualified_time + value = ::Time.utc(2000, 1, 1, 12, 30, 0, 999999) + type = ActiveRecord::Type::Time.new + + assert_equal "'2000-01-01 12:30:00.999999'", @conn.quote(type.serialize(value)) + end + + def test_quoted_time_normalizes_date_qualified_time + value = ::Time.utc(2018, 3, 11, 12, 30, 0, 999999) + type = ActiveRecord::Type::Time.new + + assert_equal "'2000-01-01 12:30:00.999999'", @conn.quote(type.serialize(value)) + end + + def test_quoted_time_dst_utc + with_env_tz "America/New_York" do + with_timezone_config default: :utc do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getutc.to_s(:db).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") + + assert_equal expected, @conn.quoted_time(t) + end + end + end + + def test_quoted_time_dst_local + with_env_tz "America/New_York" do + with_timezone_config default: :local do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") + + assert_equal expected, @conn.quoted_time(t) + 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..56ceb45040 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_adapter_test.rb @@ -0,0 +1,652 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/owner" +require "tempfile" +require "support/ddl_helper" + +module ActiveRecord + module ConnectionAdapters + class SQLite3AdapterTest < ActiveRecord::SQLite3TestCase + include DdlHelper + + self.use_transactional_tests = 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.drop_table "ex", if_exists: true + 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_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 <<~SQL + SELECT #{select} + FROM #{Owner.table_name} + WHERE #{Owner.primary_key} = #{owner.id} + SQL + + assert_not(result.rows.first.include?("blob"), "should not store blobs") + ensure + owner.delete + end + + def test_exec_insert + with_example_table do + vals = [Relation::QueryAttribute.new("number", 10, Type::Value.new)] + @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_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, [Relation::QueryAttribute.new(nil, 1, Type::Value.new)]) + + 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")') + + result = @conn.exec_query( + "SELECT id, data FROM ex WHERE id = ?", nil, [Relation::QueryAttribute.new("id", "1-fuu", Type::Integer.new)]) + + 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(<<~SQL) + CREATE TABLE IF NOT EXISTS dual_encodings ( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar(255), + data binary + ) + SQL + str = (+"\x80").force_encoding("ASCII-8BIT") + binary = DualEncoding.new name: "いただきます!", data: str + binary.save! + assert_equal str, binary.data + ensure + DualEncoding.connection.drop_table "dual_encodings", if_exists: true + 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_logged + with_example_table do + sql = "INSERT INTO ex (number) VALUES (10)" + name = "foo" + assert_logged [[sql, name, []]] do + @conn.insert(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, 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 name <> 'sqlite_sequence' AND type IN ('table') + SQL + assert_logged [[sql.squish, "SCHEMA", []]] do + @conn.tables + end + end + + def test_table_exists_logs_name + with_example_table do + sql = <<~SQL + SELECT name FROM sqlite_master WHERE name <> 'sqlite_sequence' AND name = 'ex' AND type IN ('table') + 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(&:name) + assert_equal 2, columns.length + assert_equal %w{ id number }.sort, columns.map(&:name) + assert_equal [nil, nil], columns.map(&:default) + assert_equal [true, true], columns.map(&: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 + + if ActiveRecord::Base.connection.supports_expression_index? + def test_expression_index + with_example_table do + @conn.add_index "ex", "max(id, number)", name: "expression" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "max(id, number)", index.columns + end + end + + def test_expression_index_with_where + with_example_table do + @conn.add_index "ex", "id % 10, max(id, number)", name: "expression", where: "id > 1000" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "id % 10, max(id, number)", index.columns + assert_equal "id > 1000", index.where + end + end + + def test_complicated_expression + with_example_table do + @conn.execute "CREATE INDEX expression ON ex (id % 10, (CASE WHEN number > 0 THEN max(id, number) END))WHERE(id > 1000)" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "id % 10, (CASE WHEN number > 0 THEN max(id, number) END)", index.columns + assert_equal "(id > 1000)", index.where + end + end + + def test_not_everything_an_expression + with_example_table do + @conn.add_index "ex", "id, max(id, number)", name: "expression" + index = @conn.indexes("ex").find { |idx| idx.name == "expression" } + assert_equal "id, max(id, number)", index.columns + end + 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 + + class Barcode < ActiveRecord::Base + self.primary_key = "code" + end + + def test_copy_table_with_existing_records_have_custom_primary_key + connection = Barcode.connection + connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true) do |t| + t.text :other_attr + end + code = "214fe0c2-dd47-46df-b53b-66090b3c1d40" + Barcode.create!(code: code, other_attr: "xxx") + + connection.remove_column("barcodes", "other_attr") + + assert_equal code, Barcode.first.id + ensure + Barcode.reset_column_information + end + + def test_copy_table_with_composite_primary_keys + connection = Barcode.connection + connection.create_table(:barcodes, primary_key: ["region", "code"], force: true) do |t| + t.string :region + t.string :code + t.text :other_attr + end + region = "US" + code = "214fe0c2-dd47-46df-b53b-66090b3c1d40" + Barcode.create!(region: region, code: code, other_attr: "xxx") + + connection.remove_column("barcodes", "other_attr") + + assert_equal ["region", "code"], connection.primary_keys("barcodes") + + barcode = Barcode.first + assert_equal region, barcode.region + assert_equal code, barcode.code + ensure + Barcode.reset_column_information + end + + def test_custom_primary_key_in_create_table + connection = Barcode.connection + connection.create_table :barcodes, id: false, force: true do |t| + t.primary_key :id, :string + end + + assert_equal "id", connection.primary_key("barcodes") + + custom_pk = Barcode.columns_hash["id"] + + assert_equal :string, custom_pk.type + assert_not custom_pk.null + ensure + Barcode.reset_column_information + end + + def test_custom_primary_key_in_change_table + connection = Barcode.connection + connection.create_table :barcodes, id: false, force: true do |t| + t.integer :dummy + end + connection.change_table :barcodes do |t| + t.primary_key :id, :string + end + + assert_equal "id", connection.primary_key("barcodes") + + custom_pk = Barcode.columns_hash["id"] + + assert_equal :string, custom_pk.type + assert_not custom_pk.null + ensure + Barcode.reset_column_information + end + + def test_add_column_with_custom_primary_key + connection = Barcode.connection + connection.create_table :barcodes, id: false, force: true do |t| + t.integer :dummy + end + connection.add_column :barcodes, :id, :string, primary_key: true + + assert_equal "id", connection.primary_key("barcodes") + + custom_pk = Barcode.columns_hash["id"] + + assert_equal :string, custom_pk.type + assert_not custom_pk.null + ensure + Barcode.reset_column_information + end + + def test_remove_column_preserves_partial_indexes + connection = Barcode.connection + connection.create_table :barcodes, force: true do |t| + t.string :code + t.string :region + t.boolean :bool_attr + + t.index :code, unique: true, where: :bool_attr, name: "partial" + end + connection.remove_column :barcodes, :region + + index = connection.indexes("barcodes").find { |idx| idx.name == "partial" } + assert_equal "bool_attr", index.where + ensure + Barcode.reset_column_information + end + + def test_supports_extensions + assert_not @conn.supports_extensions?, "does not support extensions" + end + + def test_respond_to_enable_extension + assert_respond_to @conn, :enable_extension + end + + def test_respond_to_disable_extension + assert_respond_to @conn, :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.stub(:step, -> { raise ::SQLite3::BusyException.new("busy") }) do + assert_called(statement, :columns, returns: []) do + assert_called(statement, :close) do + ::SQLite3::Statement.stub(:new, statement) do + assert_raises ActiveRecord::StatementInvalid do + @conn.exec_query "select * from statement_test" + end + end + end + end + end + end + + def test_deprecate_valid_alter_table_type + assert_deprecated { @conn.valid_alter_table_type?(:string) } + end + + def test_db_is_not_readonly_when_readonly_option_is_false + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3", + readonly: false + + assert_not_predicate conn.raw_connection, :readonly? + end + + def test_db_is_not_readonly_when_readonly_option_is_unspecified + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3" + + assert_not_predicate conn.raw_connection, :readonly? + end + + def test_db_is_readonly_when_readonly_option_is_true + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3", + readonly: true + + assert_predicate conn.raw_connection, :readonly? + end + + def test_writes_are_not_permitted_to_readonly_databases + conn = Base.sqlite3_connection database: ":memory:", + adapter: "sqlite3", + readonly: true + + assert_raises(ActiveRecord::StatementInvalid, /SQLite3::ReadOnlyException/) do + conn.execute("CREATE TABLE test(id integer)") + end + end + + def test_errors_when_an_insert_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + end + end + end + end + + def test_errors_when_an_update_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("UPDATE ex SET data = '9989' WHERE data = '138853948594'") + end + end + end + end + + def test_errors_when_a_delete_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("DELETE FROM ex where data = '138853948594'") + end + end + end + end + + def test_errors_when_a_replace_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + assert_raises(ActiveRecord::ReadOnlyError) do + @conn.while_preventing_writes do + @conn.execute("REPLACE INTO ex (data) VALUES ('249823948')") + end + end + end + end + + def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes + with_example_table "id int, data string" do + @conn.execute("INSERT INTO ex (data) VALUES ('138853948594')") + + @conn.while_preventing_writes do + assert_equal 1, @conn.execute("SELECT data from ex WHERE data = '138853948594'").count + end + 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..cfc9853aba --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/sqlite3_create_folder_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/owner" + +module ActiveRecord + module ConnectionAdapters + class SQLite3CreateFolder < ActiveRecord::SQLite3TestCase + 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") + ensure + @conn.disconnect! if @conn + 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..61002435a4 --- /dev/null +++ b/activerecord/test/cases/adapters/sqlite3/statement_pool_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "cases/helper" + +class SQLite3StatementPoolTest < ActiveRecord::SQLite3TestCase + if Process.respond_to?(:fork) + def test_cache_is_per_pid + cache = ActiveRecord::ConnectionAdapters::SQLite3Adapter::StatementPool.new(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 diff --git a/activerecord/test/cases/aggregations_test.rb b/activerecord/test/cases/aggregations_test.rb new file mode 100644 index 0000000000..d270175af4 --- /dev/null +++ b/activerecord/test/cases/aggregations_test.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +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(FrozenError) { 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 + + def test_assigning_hash_to_custom_converter + customers(:barney).fullname = { first: "Barney", last: "Stinson" } + assert_equal "Barney STINSON", customers(:barney).name + end + + def test_assigning_hash_without_custom_converter + customers(:barney).fullname_no_converter = { first: "Barney", last: "Stinson" } + assert_equal({ first: "Barney", last: "Stinson" }.to_s, customers(:barney).name) + 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..f05dcac7dd --- /dev/null +++ b/activerecord/test/cases/ar_schema_test.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ActiveRecordSchemaTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + setup do + @original_verbose = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + @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 + @connection.drop_table :has_timestamps rescue nil + @connection.drop_table :multiple_indexes rescue nil + ActiveRecord::SchemaMigration.delete_all rescue nil + ActiveRecord::Migration.verbose = @original_verbose + end + + def test_has_primary_key + old_primary_key_prefix_type = ActiveRecord::Base.primary_key_prefix_type + ActiveRecord::Base.primary_key_prefix_type = :table_name_with_underscore + assert_equal "version", 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, @connection.migration_context.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, @connection.migration_context.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 + + def test_schema_load_with_multiple_indexes_for_column_of_different_names + ActiveRecord::Schema.define do + create_table :multiple_indexes do |t| + t.string "foo" + t.index ["foo"], name: "multiple_indexes_foo_1" + t.index ["foo"], name: "multiple_indexes_foo_2" + end + end + + indexes = @connection.indexes("multiple_indexes") + + assert_equal 2, indexes.length + assert_equal ["multiple_indexes_foo_1", "multiple_indexes_foo_2"], indexes.collect(&:name).sort + end + + def test_timestamps_without_null_set_null_to_false_on_create_table + ActiveRecord::Schema.define do + create_table :has_timestamps do |t| + t.timestamps + end + end + + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null + end + + def test_timestamps_without_null_set_null_to_false_on_change_table + ActiveRecord::Schema.define do + create_table :has_timestamps + + change_table :has_timestamps do |t| + t.timestamps default: Time.now + end + end + + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null + end + + def test_timestamps_without_null_set_null_to_false_on_add_timestamps + ActiveRecord::Schema.define do + create_table :has_timestamps + add_timestamps :has_timestamps, default: Time.now + end + + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "created_at" }.null + assert_not @connection.columns(:has_timestamps).find { |c| c.name == "updated_at" }.null + end +end diff --git a/activerecord/test/cases/arel/attributes/attribute_test.rb b/activerecord/test/cases/arel/attributes/attribute_test.rb new file mode 100644 index 0000000000..671e273543 --- /dev/null +++ b/activerecord/test/cases/arel/attributes/attribute_test.rb @@ -0,0 +1,1015 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "ostruct" + +module Arel + module Attributes + class AttributeTest < Arel::Spec + describe "#not_eq" do + it "should create a NotEqual node" do + relation = Table.new(:users) + relation[:id].not_eq(10).must_be_kind_of Nodes::NotEqual + end + + it "should generate != in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" != 10 + } + end + + it "should handle nil" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq(nil) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" IS NOT NULL + } + end + end + + describe "#not_eq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_eq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" != 1 OR "users"."id" != 2) + } + end + end + + describe "#not_eq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_eq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_eq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" != 1 AND "users"."id" != 2) + } + end + end + + describe "#gt" do + it "should create a GreaterThan node" do + relation = Table.new(:users) + relation[:id].gt(10).must_be_kind_of Nodes::GreaterThan + end + + it "should generate > in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gt(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" > 10 + } + end + + it "should handle comparing with a subquery" do + users = Table.new(:users) + + avg = users.project(users[:karma].average) + mgr = users.project(Arel.star).where(users[:karma].gt(avg)) + + mgr.to_sql.must_be_like %{ + SELECT * FROM "users" WHERE "users"."karma" > (SELECT AVG("users"."karma") FROM "users") + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].gt("fake_name") + mgr.to_sql.must_match %{"users"."name" > 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].gt(current_time) + mgr.to_sql.must_match %{"users"."created_at" > '#{current_time}'} + end + end + + describe "#gt_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gt_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gt_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" > 1 OR "users"."id" > 2) + } + end + end + + describe "#gt_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gt_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gt_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" > 1 AND "users"."id" > 2) + } + end + end + + describe "#gteq" do + it "should create a GreaterThanOrEqual node" do + relation = Table.new(:users) + relation[:id].gteq(10).must_be_kind_of Nodes::GreaterThanOrEqual + end + + it "should generate >= in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gteq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" >= 10 + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].gteq("fake_name") + mgr.to_sql.must_match %{"users"."name" >= 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].gteq(current_time) + mgr.to_sql.must_match %{"users"."created_at" >= '#{current_time}'} + end + end + + describe "#gteq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gteq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gteq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 1 OR "users"."id" >= 2) + } + end + end + + describe "#gteq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].gteq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].gteq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 1 AND "users"."id" >= 2) + } + end + end + + describe "#lt" do + it "should create a LessThan node" do + relation = Table.new(:users) + relation[:id].lt(10).must_be_kind_of Nodes::LessThan + end + + it "should generate < in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lt(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" < 10 + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].lt("fake_name") + mgr.to_sql.must_match %{"users"."name" < 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].lt(current_time) + mgr.to_sql.must_match %{"users"."created_at" < '#{current_time}'} + end + end + + describe "#lt_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lt_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lt_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" < 1 OR "users"."id" < 2) + } + end + end + + describe "#lt_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lt_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lt_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" < 1 AND "users"."id" < 2) + } + end + end + + describe "#lteq" do + it "should create a LessThanOrEqual node" do + relation = Table.new(:users) + relation[:id].lteq(10).must_be_kind_of Nodes::LessThanOrEqual + end + + it "should generate <= in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lteq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" <= 10 + } + end + + it "should accept various data types." do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].lteq("fake_name") + mgr.to_sql.must_match %{"users"."name" <= 'fake_name'} + + current_time = ::Time.now + mgr.where relation[:created_at].lteq(current_time) + mgr.to_sql.must_match %{"users"."created_at" <= '#{current_time}'} + end + end + + describe "#lteq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lteq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lteq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" <= 1 OR "users"."id" <= 2) + } + end + end + + describe "#lteq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].lteq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].lteq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" <= 1 AND "users"."id" <= 2) + } + end + end + + describe "#average" do + it "should create a AVG node" do + relation = Table.new(:users) + relation[:id].average.must_be_kind_of Nodes::Avg + end + + it "should generate the proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].average + mgr.to_sql.must_be_like %{ + SELECT AVG("users"."id") + FROM "users" + } + end + end + + describe "#maximum" do + it "should create a MAX node" do + relation = Table.new(:users) + relation[:id].maximum.must_be_kind_of Nodes::Max + end + + it "should generate proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].maximum + mgr.to_sql.must_be_like %{ + SELECT MAX("users"."id") + FROM "users" + } + end + end + + describe "#minimum" do + it "should create a Min node" do + relation = Table.new(:users) + relation[:id].minimum.must_be_kind_of Nodes::Min + end + + it "should generate proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].minimum + mgr.to_sql.must_be_like %{ + SELECT MIN("users"."id") + FROM "users" + } + end + end + + describe "#sum" do + it "should create a SUM node" do + relation = Table.new(:users) + relation[:id].sum.must_be_kind_of Nodes::Sum + end + + it "should generate the proper SQL" do + relation = Table.new(:users) + mgr = relation.project relation[:id].sum + mgr.to_sql.must_be_like %{ + SELECT SUM("users"."id") + FROM "users" + } + end + end + + describe "#count" do + it "should return a count node" do + relation = Table.new(:users) + relation[:id].count.must_be_kind_of Nodes::Count + end + + it "should take a distinct param" do + relation = Table.new(:users) + count = relation[:id].count(nil) + count.must_be_kind_of Nodes::Count + count.distinct.must_be_nil + end + end + + describe "#eq" do + it "should return an equality node" do + attribute = Attribute.new nil, nil + equality = attribute.eq 1 + equality.left.must_equal attribute + equality.right.val.must_equal 1 + equality.must_be_kind_of Nodes::Equality + end + + it "should generate = in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq(10) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" = 10 + } + end + + it "should handle nil" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq(nil) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" IS NULL + } + end + end + + describe "#eq_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].eq_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq_any([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 OR "users"."id" = 2) + } + end + + it "should not eat input" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + values = [1, 2] + mgr.where relation[:id].eq_any(values) + values.must_equal [1, 2] + end + end + + describe "#eq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].eq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 AND "users"."id" = 2) + } + end + + it "should not eat input" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + values = [1, 2] + mgr.where relation[:id].eq_all(values) + values.must_equal [1, 2] + end + end + + describe "#matches" do + it "should create a Matches node" do + relation = Table.new(:users) + relation[:name].matches("%bacon%").must_be_kind_of Nodes::Matches + end + + it "should generate LIKE in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].matches("%bacon%") + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."name" LIKE '%bacon%' + } + end + end + + describe "#matches_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].matches_any(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].matches_any(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" LIKE '%chunky%' OR "users"."name" LIKE '%bacon%') + } + end + end + + describe "#matches_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].matches_all(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].matches_all(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" LIKE '%chunky%' AND "users"."name" LIKE '%bacon%') + } + end + end + + describe "#does_not_match" do + it "should create a DoesNotMatch node" do + relation = Table.new(:users) + relation[:name].does_not_match("%bacon%").must_be_kind_of Nodes::DoesNotMatch + end + + it "should generate NOT LIKE in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match("%bacon%") + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."name" NOT LIKE '%bacon%' + } + end + end + + describe "#does_not_match_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].does_not_match_any(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_any(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" NOT LIKE '%chunky%' OR "users"."name" NOT LIKE '%bacon%') + } + end + end + + describe "#does_not_match_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:name].does_not_match_all(["%chunky%", "%bacon%"]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."name" NOT LIKE '%chunky%' AND "users"."name" NOT LIKE '%bacon%') + } + end + end + + describe "with a range" do + it "can be constructed with a standard range" do + attribute = Attribute.new nil, nil + node = attribute.between(1..3) + + node.must_equal Nodes::Between.new( + attribute, + Nodes::And.new([ + Nodes::Casted.new(1, attribute), + Nodes::Casted.new(3, attribute) + ]) + ) + end + + it "can be constructed with a range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(-::Float::INFINITY..3) + + node.must_equal Nodes::LessThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with a quoted range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(-::Float::INFINITY, 3, false)) + + node.must_equal Nodes::LessThanOrEqual.new( + attribute, + Nodes::Quoted.new(3) + ) + end + + it "can be constructed with an exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(-::Float::INFINITY...3) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with a quoted exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(-::Float::INFINITY, 3, true)) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Quoted.new(3) + ) + end + + it "can be constructed with an infinite range" do + attribute = Attribute.new nil, nil + node = attribute.between(-::Float::INFINITY..::Float::INFINITY) + + node.must_equal Nodes::NotIn.new(attribute, []) + end + + it "can be constructed with a quoted infinite range" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(-::Float::INFINITY, ::Float::INFINITY, false)) + + node.must_equal Nodes::NotIn.new(attribute, []) + end + + + it "can be constructed with a range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(0..::Float::INFINITY) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + + it "can be constructed with a quoted range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.between(quoted_range(0, ::Float::INFINITY, false)) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Quoted.new(0) + ) + end + + it "can be constructed with an exclusive range" do + attribute = Attribute.new nil, nil + node = attribute.between(0...3) + + node.must_equal Nodes::And.new([ + Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(0, attribute) + ), + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + ]) + end + + def quoted_range(begin_val, end_val, exclude) + OpenStruct.new( + begin: Nodes::Quoted.new(begin_val), + end: Nodes::Quoted.new(end_val), + exclude_end?: exclude, + ) + end + end + + describe "#in" do + it "can be constructed with a subquery" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"]) + attribute = Attribute.new nil, nil + + node = attribute.in(mgr) + + node.must_equal Nodes::In.new(attribute, mgr.ast) + end + + it "can be constructed with a list" do + attribute = Attribute.new nil, nil + node = attribute.in([1, 2, 3]) + + node.must_equal Nodes::In.new( + attribute, + [ + Nodes::Casted.new(1, attribute), + Nodes::Casted.new(2, attribute), + Nodes::Casted.new(3, attribute), + ] + ) + end + + it "can be constructed with a random object" do + attribute = Attribute.new nil, nil + random_object = Object.new + node = attribute.in(random_object) + + node.must_equal Nodes::In.new( + attribute, + Nodes::Casted.new(random_object, attribute) + ) + end + + it "should generate IN in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].in([1, 2, 3]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" IN (1, 2, 3) + } + end + end + + describe "#in_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].in_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].in_any([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" IN (1, 2) OR "users"."id" IN (3, 4)) + } + end + end + + describe "#in_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].in_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].in_all([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" IN (1, 2) AND "users"."id" IN (3, 4)) + } + end + end + + describe "with a range" do + it "can be constructed with a standard range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(1..3) + + node.must_equal Nodes::Grouping.new(Nodes::Or.new( + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(1, attribute) + ), + Nodes::GreaterThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + )) + end + + it "can be constructed with a range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(-::Float::INFINITY..3) + + node.must_equal Nodes::GreaterThan.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with an exclusive range starting from -Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(-::Float::INFINITY...3) + + node.must_equal Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + end + + it "can be constructed with an infinite range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(-::Float::INFINITY..::Float::INFINITY) + + node.must_equal Nodes::In.new(attribute, []) + end + + it "can be constructed with a range ending at Infinity" do + attribute = Attribute.new nil, nil + node = attribute.not_between(0..::Float::INFINITY) + + node.must_equal Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ) + end + + it "can be constructed with an exclusive range" do + attribute = Attribute.new nil, nil + node = attribute.not_between(0...3) + + node.must_equal Nodes::Grouping.new(Nodes::Or.new( + Nodes::LessThan.new( + attribute, + Nodes::Casted.new(0, attribute) + ), + Nodes::GreaterThanOrEqual.new( + attribute, + Nodes::Casted.new(3, attribute) + ) + )) + end + end + + describe "#not_in" do + it "can be constructed with a subquery" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:name].does_not_match_all(["%chunky%", "%bacon%"]) + attribute = Attribute.new nil, nil + + node = attribute.not_in(mgr) + + node.must_equal Nodes::NotIn.new(attribute, mgr.ast) + end + + it "can be constructed with a Union" do + relation = Table.new(:users) + mgr1 = relation.project(relation[:id]) + mgr2 = relation.project(relation[:id]) + + union = mgr1.union(mgr2) + node = relation[:id].in(union) + node.to_sql.must_be_like %{ + "users"."id" IN (( SELECT "users"."id" FROM "users" UNION SELECT "users"."id" FROM "users" )) + } + end + + it "can be constructed with a list" do + attribute = Attribute.new nil, nil + node = attribute.not_in([1, 2, 3]) + + node.must_equal Nodes::NotIn.new( + attribute, + [ + Nodes::Casted.new(1, attribute), + Nodes::Casted.new(2, attribute), + Nodes::Casted.new(3, attribute), + ] + ) + end + + it "can be constructed with a random object" do + attribute = Attribute.new nil, nil + random_object = Object.new + node = attribute.not_in(random_object) + + node.must_equal Nodes::NotIn.new( + attribute, + Nodes::Casted.new(random_object, attribute) + ) + end + + it "should generate NOT IN in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_in([1, 2, 3]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE "users"."id" NOT IN (1, 2, 3) + } + end + end + + describe "#not_in_any" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_in_any([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ORs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_in_any([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" NOT IN (1, 2) OR "users"."id" NOT IN (3, 4)) + } + end + end + + describe "#not_in_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].not_in_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].not_in_all([[1, 2], [3, 4]]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" NOT IN (1, 2) AND "users"."id" NOT IN (3, 4)) + } + end + end + + describe "#eq_all" do + it "should create a Grouping node" do + relation = Table.new(:users) + relation[:id].eq_all([1, 2]).must_be_kind_of Nodes::Grouping + end + + it "should generate ANDs in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.where relation[:id].eq_all([1, 2]) + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" WHERE ("users"."id" = 1 AND "users"."id" = 2) + } + end + end + + describe "#asc" do + it "should create an Ascending node" do + relation = Table.new(:users) + relation[:id].asc.must_be_kind_of Nodes::Ascending + end + + it "should generate ASC in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.order relation[:id].asc + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" ORDER BY "users"."id" ASC + } + end + end + + describe "#desc" do + it "should create a Descending node" do + relation = Table.new(:users) + relation[:id].desc.must_be_kind_of Nodes::Descending + end + + it "should generate DESC in sql" do + relation = Table.new(:users) + mgr = relation.project relation[:id] + mgr.order relation[:id].desc + mgr.to_sql.must_be_like %{ + SELECT "users"."id" FROM "users" ORDER BY "users"."id" DESC + } + end + end + + describe "equality" do + describe "#to_sql" do + it "should produce sql" do + table = Table.new :users + condition = table["id"].eq 1 + condition.to_sql.must_equal '"users"."id" = 1' + end + end + end + + describe "type casting" do + it "does not type cast by default" do + table = Table.new(:foo) + condition = table["id"].eq("1") + + assert_not table.able_to_type_cast? + condition.to_sql.must_equal %("foo"."id" = '1') + end + + it "type casts when given an explicit caster" do + fake_caster = Object.new + def fake_caster.type_cast_for_database(attr_name, value) + if attr_name == "id" + value.to_i + else + value + end + end + table = Table.new(:foo, type_caster: fake_caster) + condition = table["id"].eq("1").and(table["other_id"].eq("2")) + + assert table.able_to_type_cast? + condition.to_sql.must_equal %("foo"."id" = 1 AND "foo"."other_id" = '2') + end + + it "does not type cast SqlLiteral nodes" do + fake_caster = Object.new + def fake_caster.type_cast_for_database(attr_name, value) + value.to_i + end + table = Table.new(:foo, type_caster: fake_caster) + condition = table["id"].eq(Arel.sql("(select 1)")) + + assert table.able_to_type_cast? + condition.to_sql.must_equal %("foo"."id" = (select 1)) + end + end + end + end +end diff --git a/activerecord/test/cases/arel/attributes/math_test.rb b/activerecord/test/cases/arel/attributes/math_test.rb new file mode 100644 index 0000000000..41eea217c0 --- /dev/null +++ b/activerecord/test/cases/arel/attributes/math_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Attributes + class MathTest < Arel::Spec + %i[* /].each do |math_operator| + it "average should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].average.public_send(math_operator, 2)).to_sql.must_be_like %{ + AVG("users"."id") #{math_operator} 2 + } + end + + it "count should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].count.public_send(math_operator, 2)).to_sql.must_be_like %{ + COUNT("users"."id") #{math_operator} 2 + } + end + + it "maximum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].maximum.public_send(math_operator, 2)).to_sql.must_be_like %{ + MAX("users"."id") #{math_operator} 2 + } + end + + it "minimum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].minimum.public_send(math_operator, 2)).to_sql.must_be_like %{ + MIN("users"."id") #{math_operator} 2 + } + end + + it "attribute node should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].public_send(math_operator, 2)).to_sql.must_be_like %{ + "users"."id" #{math_operator} 2 + } + end + end + + %i[+ - & | ^ << >>].each do |math_operator| + it "average should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].average.public_send(math_operator, 2)).to_sql.must_be_like %{ + (AVG("users"."id") #{math_operator} 2) + } + end + + it "count should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].count.public_send(math_operator, 2)).to_sql.must_be_like %{ + (COUNT("users"."id") #{math_operator} 2) + } + end + + it "maximum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].maximum.public_send(math_operator, 2)).to_sql.must_be_like %{ + (MAX("users"."id") #{math_operator} 2) + } + end + + it "minimum should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].minimum.public_send(math_operator, 2)).to_sql.must_be_like %{ + (MIN("users"."id") #{math_operator} 2) + } + end + + it "attribute node should be compatible with #{math_operator}" do + table = Arel::Table.new :users + (table[:id].public_send(math_operator, 2)).to_sql.must_be_like %{ + ("users"."id" #{math_operator} 2) + } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/attributes_test.rb b/activerecord/test/cases/arel/attributes_test.rb new file mode 100644 index 0000000000..b00af4bd29 --- /dev/null +++ b/activerecord/test/cases/arel/attributes_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + describe "Attributes" do + it "responds to lower" do + relation = Table.new(:users) + attribute = relation[:foo] + node = attribute.lower + assert_equal "LOWER", node.name + assert_equal [attribute], node.expressions + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Attribute.new("foo", "bar"), Attribute.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Attribute.new("foo", "bar"), Attribute.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + + describe "for" do + it "deals with unknown column types" do + column = Struct.new(:type).new :crazy + Attributes.for(column).must_equal Attributes::Undefined + end + + it "returns the correct constant for strings" do + [:string, :text, :binary].each do |type| + column = Struct.new(:type).new type + Attributes.for(column).must_equal Attributes::String + end + end + + it "returns the correct constant for ints" do + column = Struct.new(:type).new :integer + Attributes.for(column).must_equal Attributes::Integer + end + + it "returns the correct constant for floats" do + column = Struct.new(:type).new :float + Attributes.for(column).must_equal Attributes::Float + end + + it "returns the correct constant for decimals" do + column = Struct.new(:type).new :decimal + Attributes.for(column).must_equal Attributes::Decimal + end + + it "returns the correct constant for boolean" do + column = Struct.new(:type).new :boolean + Attributes.for(column).must_equal Attributes::Boolean + end + + it "returns the correct constant for time" do + [:date, :datetime, :timestamp, :time].each do |type| + column = Struct.new(:type).new type + Attributes.for(column).must_equal Attributes::Time + end + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/bind_test.rb b/activerecord/test/cases/arel/collectors/bind_test.rb new file mode 100644 index 0000000000..ffa9b15f66 --- /dev/null +++ b/activerecord/test/cases/arel/collectors/bind_test.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "arel/collectors/bind" + +module Arel + module Collectors + class TestBind < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def collect(node) + @visitor.accept(node, Collectors::Bind.new) + end + + def compile(node) + collect(node).value + end + + def ast_with_binds(bvs) + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new(bvs.shift))) + manager.where(table[:name].eq(Nodes::BindParam.new(bvs.shift))) + manager.ast + end + + def test_compile_gathers_all_bind_params + binds = compile(ast_with_binds(["hello", "world"])) + assert_equal ["hello", "world"], binds + + binds = compile(ast_with_binds(["hello2", "world3"])) + assert_equal ["hello2", "world3"], binds + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/composite_test.rb b/activerecord/test/cases/arel/collectors/composite_test.rb new file mode 100644 index 0000000000..545637496f --- /dev/null +++ b/activerecord/test/cases/arel/collectors/composite_test.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require_relative "../helper" + +require "arel/collectors/bind" +require "arel/collectors/composite" + +module Arel + module Collectors + class TestComposite < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def collect(node) + sql_collector = Collectors::SQLString.new + bind_collector = Collectors::Bind.new + collector = Collectors::Composite.new(sql_collector, bind_collector) + @visitor.accept(node, collector) + end + + def compile(node) + collect(node).value + end + + def ast_with_binds(bvs) + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new(bvs.shift))) + manager.where(table[:name].eq(Nodes::BindParam.new(bvs.shift))) + manager.ast + end + + def test_composite_collector_performs_multiple_collections_at_once + sql, binds = compile(ast_with_binds(["hello", "world"])) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + assert_equal ["hello", "world"], binds + + sql, binds = compile(ast_with_binds(["hello2", "world3"])) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + assert_equal ["hello2", "world3"], binds + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/sql_string_test.rb b/activerecord/test/cases/arel/collectors/sql_string_test.rb new file mode 100644 index 0000000000..443c7eb54b --- /dev/null +++ b/activerecord/test/cases/arel/collectors/sql_string_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Collectors + class TestSqlString < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def collect(node) + @visitor.accept(node, Collectors::SQLString.new) + end + + def compile(node) + collect(node).value + end + + def ast_with_binds + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new("hello"))) + manager.where(table[:name].eq(Nodes::BindParam.new("world"))) + manager.ast + end + + def test_compile + sql = compile(ast_with_binds) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = ? AND "users"."name" = ?', sql + end + + def test_returned_sql_uses_utf8_encoding + sql = compile(ast_with_binds) + assert_equal sql.encoding, Encoding::UTF_8 + end + end + end +end diff --git a/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb b/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb new file mode 100644 index 0000000000..255c8e79e9 --- /dev/null +++ b/activerecord/test/cases/arel/collectors/substitute_bind_collector_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "arel/collectors/substitute_binds" +require "arel/collectors/sql_string" + +module Arel + module Collectors + class TestSubstituteBindCollector < Arel::Test + def setup + @conn = FakeRecord::Base.new + @visitor = Visitors::ToSql.new @conn.connection + super + end + + def ast_with_binds + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.where(table[:age].eq(Nodes::BindParam.new("hello"))) + manager.where(table[:name].eq(Nodes::BindParam.new("world"))) + manager.ast + end + + def compile(node, quoter) + collector = Collectors::SubstituteBinds.new(quoter, Collectors::SQLString.new) + @visitor.accept(node, collector).value + end + + def test_compile + quoter = Object.new + def quoter.quote(val) + val.to_s + end + sql = compile(ast_with_binds, quoter) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = hello AND "users"."name" = world', sql + end + + def test_quoting_is_delegated_to_quoter + quoter = Object.new + def quoter.quote(val) + val.inspect + end + sql = compile(ast_with_binds, quoter) + assert_equal 'SELECT FROM "users" WHERE "users"."age" = "hello" AND "users"."name" = "world"', sql + end + end + end +end diff --git a/activerecord/test/cases/arel/crud_test.rb b/activerecord/test/cases/arel/crud_test.rb new file mode 100644 index 0000000000..f3cdd8927f --- /dev/null +++ b/activerecord/test/cases/arel/crud_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class FakeCrudder < SelectManager + class FakeEngine + attr_reader :calls, :connection_pool, :spec, :config + + def initialize + @calls = [] + @connection_pool = self + @spec = self + @config = { adapter: "sqlite3" } + end + + def connection; self end + + def method_missing(name, *args) + @calls << [name, args] + end + end + + include Crud + + attr_reader :engine + attr_accessor :ctx + + def initialize(engine = FakeEngine.new) + super + end + end + + describe "crud" do + describe "insert" do + it "should call insert on the connection" do + table = Table.new :users + fc = FakeCrudder.new + fc.from table + im = fc.compile_insert [[table[:id], "foo"]] + assert_instance_of Arel::InsertManager, im + end + end + + describe "update" do + it "should call update on the connection" do + table = Table.new :users + fc = FakeCrudder.new + fc.from table + stmt = fc.compile_update [[table[:id], "foo"]], Arel::Attributes::Attribute.new(table, "id") + assert_instance_of Arel::UpdateManager, stmt + end + end + + describe "delete" do + it "should call delete on the connection" do + table = Table.new :users + fc = FakeCrudder.new + fc.from table + stmt = fc.compile_delete + assert_instance_of Arel::DeleteManager, stmt + end + end + end +end diff --git a/activerecord/test/cases/arel/delete_manager_test.rb b/activerecord/test/cases/arel/delete_manager_test.rb new file mode 100644 index 0000000000..0bad02f4d2 --- /dev/null +++ b/activerecord/test/cases/arel/delete_manager_test.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class DeleteManagerTest < Arel::Spec + describe "new" do + it "takes an engine" do + Arel::DeleteManager.new + end + end + + it "handles limit properly" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.take 10 + dm.from table + dm.key = table[:id] + assert_match(/LIMIT 10/, dm.to_sql) + end + + describe "from" do + it "uses from" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.from table + dm.to_sql.must_be_like %{ DELETE FROM "users" } + end + + it "chains" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.from(table).must_equal dm + end + end + + describe "where" do + it "uses where values" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.from table + dm.where table[:id].eq(10) + dm.to_sql.must_be_like %{ DELETE FROM "users" WHERE "users"."id" = 10} + end + + it "chains" do + table = Table.new(:users) + dm = Arel::DeleteManager.new + dm.where(table[:id].eq(10)).must_equal dm + end + end + end +end diff --git a/activerecord/test/cases/arel/factory_methods_test.rb b/activerecord/test/cases/arel/factory_methods_test.rb new file mode 100644 index 0000000000..26d2cdd08d --- /dev/null +++ b/activerecord/test/cases/arel/factory_methods_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + module FactoryMethods + class TestFactoryMethods < Arel::Test + class Factory + include Arel::FactoryMethods + end + + def setup + @factory = Factory.new + end + + def test_create_join + join = @factory.create_join :one, :two + assert_kind_of Nodes::Join, join + assert_equal :two, join.right + end + + def test_create_on + on = @factory.create_on :one + assert_instance_of Nodes::On, on + assert_equal :one, on.expr + end + + def test_create_true + true_node = @factory.create_true + assert_instance_of Nodes::True, true_node + end + + def test_create_false + false_node = @factory.create_false + assert_instance_of Nodes::False, false_node + end + + def test_lower + lower = @factory.lower :one + assert_instance_of Nodes::NamedFunction, lower + assert_equal "LOWER", lower.name + assert_equal [:one], lower.expressions.map(&:expr) + end + end + end +end diff --git a/activerecord/test/cases/arel/helper.rb b/activerecord/test/cases/arel/helper.rb new file mode 100644 index 0000000000..f8ce658440 --- /dev/null +++ b/activerecord/test/cases/arel/helper.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "active_support" +require "minitest/autorun" +require "arel" + +require_relative "support/fake_record" + +class Object + def must_be_like(other) + gsub(/\s+/, " ").strip.must_equal other.gsub(/\s+/, " ").strip + end +end + +module Arel + class Test < ActiveSupport::TestCase + def setup + super + @arel_engine = Arel::Table.engine + Arel::Table.engine = FakeRecord::Base.new + end + + def teardown + Arel::Table.engine = @arel_engine if defined? @arel_engine + super + end + end + + class Spec < Minitest::Spec + before do + @arel_engine = Arel::Table.engine + Arel::Table.engine = FakeRecord::Base.new + end + + after do + Arel::Table.engine = @arel_engine if defined? @arel_engine + end + include ActiveSupport::Testing::Assertions + + # test/unit backwards compatibility methods + alias :assert_no_match :refute_match + alias :assert_not_equal :refute_equal + alias :assert_not_same :refute_same + end +end diff --git a/activerecord/test/cases/arel/insert_manager_test.rb b/activerecord/test/cases/arel/insert_manager_test.rb new file mode 100644 index 0000000000..2376ad8d37 --- /dev/null +++ b/activerecord/test/cases/arel/insert_manager_test.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class InsertManagerTest < Arel::Spec + describe "new" do + it "takes an engine" do + Arel::InsertManager.new + end + end + + describe "insert" do + it "can create a Values node" do + manager = Arel::InsertManager.new + values = manager.create_values %w{ a b }, %w{ c d } + + assert_kind_of Arel::Nodes::Values, values + assert_equal %w{ a b }, values.left + assert_equal %w{ c d }, values.right + end + + it "allows sql literals" do + manager = Arel::InsertManager.new + manager.into Table.new(:users) + manager.values = manager.create_values [Arel.sql("*")], %w{ a } + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" VALUES (*) + } + end + + it "works with multiple values" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + + manager.columns << table[:id] + manager.columns << table[:name] + + manager.values = manager.create_values_list([ + %w{1 david}, + %w{2 kir}, + ["3", Arel.sql("DEFAULT")], + ]) + + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" (\"id\", \"name\") VALUES ('1', 'david'), ('2', 'kir'), ('3', DEFAULT) + } + end + + it "literals in multiple values are not escaped" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + + manager.columns << table[:name] + + manager.values = manager.create_values_list([ + [Arel.sql("*")], + [Arel.sql("DEFAULT")], + ]) + + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" (\"name\") VALUES (*), (DEFAULT) + } + end + + it "works with multiple single values" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + + manager.columns << table[:name] + + manager.values = manager.create_values_list([ + %w{david}, + %w{kir}, + [Arel.sql("DEFAULT")], + ]) + + manager.to_sql.must_be_like %{ + INSERT INTO \"users\" (\"name\") VALUES ('david'), ('kir'), (DEFAULT) + } + end + + it "inserts false" do + table = Table.new(:users) + manager = Arel::InsertManager.new + + manager.insert [[table[:bool], false]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("bool") VALUES ('f') + } + end + + it "inserts null" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.insert [[table[:id], nil]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id") VALUES (NULL) + } + end + + it "inserts time" do + table = Table.new(:users) + manager = Arel::InsertManager.new + + time = Time.now + attribute = table[:created_at] + + manager.insert [[attribute, time]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("created_at") VALUES (#{Table.engine.connection.quote time}) + } + end + + it "takes a list of lists" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.into table + manager.insert [[table[:id], 1], [table[:name], "aaron"]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + } + end + + it "defaults the table" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.insert [[table[:id], 1], [table[:name], "aaron"]] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + } + end + + it "noop for empty list" do + table = Table.new(:users) + manager = Arel::InsertManager.new + manager.insert [[table[:id], 1]] + manager.insert [] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id") VALUES (1) + } + end + + it "is chainable" do + table = Table.new(:users) + manager = Arel::InsertManager.new + insert_result = manager.insert [[table[:id], 1]] + assert_equal manager, insert_result + end + end + + describe "into" do + it "takes a Table and chains" do + manager = Arel::InsertManager.new + manager.into(Table.new(:users)).must_equal manager + end + + it "converts to sql" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + manager.to_sql.must_be_like %{ + INSERT INTO "users" + } + end + end + + describe "columns" do + it "converts to sql" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + manager.columns << table[:id] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id") + } + end + end + + describe "values" do + it "converts to sql" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + + manager.values = Nodes::Values.new [1] + manager.to_sql.must_be_like %{ + INSERT INTO "users" VALUES (1) + } + end + + it "accepts sql literals" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + + manager.values = Arel.sql("DEFAULT VALUES") + manager.to_sql.must_be_like %{ + INSERT INTO "users" DEFAULT VALUES + } + end + end + + describe "combo" do + it "combines columns and values list in order" do + table = Table.new :users + manager = Arel::InsertManager.new + manager.into table + + manager.values = Nodes::Values.new [1, "aaron"] + manager.columns << table[:id] + manager.columns << table[:name] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") VALUES (1, 'aaron') + } + end + end + + describe "select" do + it "accepts a select query in place of a VALUES clause" do + table = Table.new :users + + manager = Arel::InsertManager.new + manager.into table + + select = Arel::SelectManager.new + select.project Arel.sql("1") + select.project Arel.sql('"aaron"') + + manager.select select + manager.columns << table[:id] + manager.columns << table[:name] + manager.to_sql.must_be_like %{ + INSERT INTO "users" ("id", "name") (SELECT 1, "aaron") + } + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/and_test.rb b/activerecord/test/cases/arel/nodes/and_test.rb new file mode 100644 index 0000000000..eff54abd91 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/and_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "And" do + describe "equality" do + it "is equal with equal ivars" do + array = [And.new(["foo", "bar"]), And.new(["foo", "bar"])] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [And.new(["foo", "bar"]), And.new(["foo", "baz"])] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/as_test.rb b/activerecord/test/cases/arel/nodes/as_test.rb new file mode 100644 index 0000000000..1169ea11c9 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/as_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "As" do + describe "#as" do + it "makes an AS node" do + attr = Table.new(:users)[:id] + as = attr.as(Arel.sql("foo")) + assert_equal attr, as.left + assert_equal "foo", as.right + end + + it "converts right to SqlLiteral if a string" do + attr = Table.new(:users)[:id] + as = attr.as("foo") + assert_kind_of Arel::Nodes::SqlLiteral, as.right + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [As.new("foo", "bar"), As.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [As.new("foo", "bar"), As.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/ascending_test.rb b/activerecord/test/cases/arel/nodes/ascending_test.rb new file mode 100644 index 0000000000..4811e6ff5b --- /dev/null +++ b/activerecord/test/cases/arel/nodes/ascending_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestAscending < Arel::Test + def test_construct + ascending = Ascending.new "zomg" + assert_equal "zomg", ascending.expr + end + + def test_reverse + ascending = Ascending.new "zomg" + descending = ascending.reverse + assert_kind_of Descending, descending + assert_equal ascending.expr, descending.expr + end + + def test_direction + ascending = Ascending.new "zomg" + assert_equal :asc, ascending.direction + end + + def test_ascending? + ascending = Ascending.new "zomg" + assert ascending.ascending? + end + + def test_descending? + ascending = Ascending.new "zomg" + assert_not ascending.descending? + end + + def test_equality_with_same_ivars + array = [Ascending.new("zomg"), Ascending.new("zomg")] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [Ascending.new("zomg"), Ascending.new("zomg!")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/bin_test.rb b/activerecord/test/cases/arel/nodes/bin_test.rb new file mode 100644 index 0000000000..ee2ec3cf2f --- /dev/null +++ b/activerecord/test/cases/arel/nodes/bin_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestBin < Arel::Test + def test_new + assert Arel::Nodes::Bin.new("zomg") + end + + def test_default_to_sql + viz = Arel::Visitors::ToSql.new Table.engine.connection_pool + node = Arel::Nodes::Bin.new(Arel.sql("zomg")) + assert_equal "zomg", viz.accept(node, Collectors::SQLString.new).value + end + + def test_mysql_to_sql + viz = Arel::Visitors::MySQL.new Table.engine.connection_pool + node = Arel::Nodes::Bin.new(Arel.sql("zomg")) + assert_equal "BINARY zomg", viz.accept(node, Collectors::SQLString.new).value + end + + def test_equality_with_same_ivars + array = [Bin.new("zomg"), Bin.new("zomg")] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [Bin.new("zomg"), Bin.new("zomg!")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/binary_test.rb b/activerecord/test/cases/arel/nodes/binary_test.rb new file mode 100644 index 0000000000..d160e7cd9d --- /dev/null +++ b/activerecord/test/cases/arel/nodes/binary_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class NodesTest < Arel::Spec + describe "Binary" do + describe "#hash" do + it "generates a hash based on its value" do + eq = Equality.new("foo", "bar") + eq2 = Equality.new("foo", "bar") + eq3 = Equality.new("bar", "baz") + + assert_equal eq.hash, eq2.hash + assert_not_equal eq.hash, eq3.hash + end + + it "generates a hash specific to its class" do + eq = Equality.new("foo", "bar") + neq = NotEqual.new("foo", "bar") + + assert_not_equal eq.hash, neq.hash + end + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/bind_param_test.rb b/activerecord/test/cases/arel/nodes/bind_param_test.rb new file mode 100644 index 0000000000..37a362ece4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/bind_param_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "BindParam" do + it "is equal to other bind params with the same value" do + BindParam.new(1).must_equal(BindParam.new(1)) + BindParam.new("foo").must_equal(BindParam.new("foo")) + end + + it "is not equal to other nodes" do + BindParam.new(nil).wont_equal(Node.new) + end + + it "is not equal to bind params with different values" do + BindParam.new(1).wont_equal(BindParam.new(2)) + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/case_test.rb b/activerecord/test/cases/arel/nodes/case_test.rb new file mode 100644 index 0000000000..89861488df --- /dev/null +++ b/activerecord/test/cases/arel/nodes/case_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class NodesTest < Arel::Spec + describe "Case" do + describe "#initialize" do + it "sets case expression from first argument" do + node = Case.new "foo" + + assert_equal "foo", node.case + end + + it "sets default case from second argument" do + node = Case.new nil, "bar" + + assert_equal "bar", node.default + end + end + + describe "#clone" do + it "clones case, conditions and default" do + foo = Nodes.build_quoted "foo" + + node = Case.new + node.case = foo + node.conditions = [When.new(foo, foo)] + node.default = foo + + dolly = node.clone + + assert_equal dolly.case, node.case + assert_not_same dolly.case, node.case + + assert_equal dolly.conditions, node.conditions + assert_not_same dolly.conditions, node.conditions + + assert_equal dolly.default, node.default + assert_not_same dolly.default, node.default + end + end + + describe "equality" do + it "is equal with equal ivars" do + foo = Nodes.build_quoted "foo" + one = Nodes.build_quoted 1 + zero = Nodes.build_quoted 0 + + case1 = Case.new foo + case1.conditions = [When.new(foo, one)] + case1.default = Else.new zero + + case2 = Case.new foo + case2.conditions = [When.new(foo, one)] + case2.default = Else.new zero + + array = [case1, case2] + + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + foo = Nodes.build_quoted "foo" + bar = Nodes.build_quoted "bar" + one = Nodes.build_quoted 1 + zero = Nodes.build_quoted 0 + + case1 = Case.new foo + case1.conditions = [When.new(foo, one)] + case1.default = Else.new zero + + case2 = Case.new foo + case2.conditions = [When.new(bar, one)] + case2.default = Else.new zero + + array = [case1, case2] + + assert_equal 2, array.uniq.size + end + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/casted_test.rb b/activerecord/test/cases/arel/nodes/casted_test.rb new file mode 100644 index 0000000000..e27f58a4e2 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/casted_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe Casted do + describe "#hash" do + it "is equal when eql? returns true" do + one = Casted.new 1, 2 + also_one = Casted.new 1, 2 + + assert_equal one.hash, also_one.hash + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/count_test.rb b/activerecord/test/cases/arel/nodes/count_test.rb new file mode 100644 index 0000000000..daabea6c4c --- /dev/null +++ b/activerecord/test/cases/arel/nodes/count_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::CountTest < Arel::Spec + describe "as" do + it "should alias the count" do + table = Arel::Table.new :users + table[:id].count.as("foo").to_sql.must_be_like %{ + COUNT("users"."id") AS foo + } + end + end + + describe "eq" do + it "should compare the count" do + table = Arel::Table.new :users + table[:id].count.eq(2).to_sql.must_be_like %{ + COUNT("users"."id") = 2 + } + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Arel::Nodes::Count.new("foo"), Arel::Nodes::Count.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Arel::Nodes::Count.new("foo"), Arel::Nodes::Count.new("foo!")] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/delete_statement_test.rb b/activerecord/test/cases/arel/nodes/delete_statement_test.rb new file mode 100644 index 0000000000..3f078063a4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/delete_statement_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::DeleteStatement do + describe "#clone" do + it "clones wheres" do + statement = Arel::Nodes::DeleteStatement.new + statement.wheres = %w[a b c] + + dolly = statement.clone + dolly.wheres.must_equal statement.wheres + dolly.wheres.wont_be_same_as statement.wheres + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::DeleteStatement.new + statement1.wheres = %w[a b c] + statement2 = Arel::Nodes::DeleteStatement.new + statement2.wheres = %w[a b c] + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::DeleteStatement.new + statement1.wheres = %w[a b c] + statement2 = Arel::Nodes::DeleteStatement.new + statement2.wheres = %w[1 2 3] + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/descending_test.rb b/activerecord/test/cases/arel/nodes/descending_test.rb new file mode 100644 index 0000000000..5f1747e1da --- /dev/null +++ b/activerecord/test/cases/arel/nodes/descending_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestDescending < Arel::Test + def test_construct + descending = Descending.new "zomg" + assert_equal "zomg", descending.expr + end + + def test_reverse + descending = Descending.new "zomg" + ascending = descending.reverse + assert_kind_of Ascending, ascending + assert_equal descending.expr, ascending.expr + end + + def test_direction + descending = Descending.new "zomg" + assert_equal :desc, descending.direction + end + + def test_ascending? + descending = Descending.new "zomg" + assert_not descending.ascending? + end + + def test_descending? + descending = Descending.new "zomg" + assert descending.descending? + end + + def test_equality_with_same_ivars + array = [Descending.new("zomg"), Descending.new("zomg")] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [Descending.new("zomg"), Descending.new("zomg!")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/distinct_test.rb b/activerecord/test/cases/arel/nodes/distinct_test.rb new file mode 100644 index 0000000000..de5f0ee588 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/distinct_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "Distinct" do + describe "equality" do + it "is equal to other distinct nodes" do + array = [Distinct.new, Distinct.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [Distinct.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/equality_test.rb b/activerecord/test/cases/arel/nodes/equality_test.rb new file mode 100644 index 0000000000..e173720e86 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/equality_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "equality" do + # FIXME: backwards compat + describe "backwards compat" do + describe "operator" do + it "returns :==" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + left.operator.must_equal :== + end + end + + describe "operand1" do + it "should equal left" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + left.left.must_equal left.operand1 + end + end + + describe "operand2" do + it "should equal right" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + left.right.must_equal left.operand2 + end + end + + describe "to_sql" do + it "takes an engine" do + engine = FakeRecord::Base.new + engine.connection.extend Module.new { + attr_accessor :quote_count + def quote(*args) @quote_count += 1; super; end + def quote_column_name(*args) @quote_count += 1; super; end + def quote_table_name(*args) @quote_count += 1; super; end + } + engine.connection.quote_count = 0 + + attr = Table.new(:users)[:id] + test = attr.eq(10) + test.to_sql engine + engine.connection.quote_count.must_equal 3 + end + end + end + + describe "or" do + it "makes an OR node" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + right = attr.eq(11) + node = left.or right + node.expr.left.must_equal left + node.expr.right.must_equal right + end + end + + describe "and" do + it "makes and AND node" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + right = attr.eq(11) + node = left.and right + node.left.must_equal left + node.right.must_equal right + end + end + + it "is equal with equal ivars" do + array = [Equality.new("foo", "bar"), Equality.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Equality.new("foo", "bar"), Equality.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/extract_test.rb b/activerecord/test/cases/arel/nodes/extract_test.rb new file mode 100644 index 0000000000..8fc1e04d67 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/extract_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::ExtractTest < Arel::Spec + it "should extract field" do + table = Arel::Table.new :users + table[:timestamp].extract("date").to_sql.must_be_like %{ + EXTRACT(DATE FROM "users"."timestamp") + } + end + + describe "as" do + it "should alias the extract" do + table = Arel::Table.new :users + table[:timestamp].extract("date").as("foo").to_sql.must_be_like %{ + EXTRACT(DATE FROM "users"."timestamp") AS foo + } + end + + it "should not mutate the extract" do + table = Arel::Table.new :users + extract = table[:timestamp].extract("date") + before = extract.dup + extract.as("foo") + assert_equal extract, before + end + end + + describe "equality" do + it "is equal with equal ivars" do + table = Arel::Table.new :users + array = [table[:attr].extract("foo"), table[:attr].extract("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + table = Arel::Table.new :users + array = [table[:attr].extract("foo"), table[:attr].extract("bar")] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/false_test.rb b/activerecord/test/cases/arel/nodes/false_test.rb new file mode 100644 index 0000000000..4ecf8e332e --- /dev/null +++ b/activerecord/test/cases/arel/nodes/false_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "False" do + describe "equality" do + it "is equal to other false nodes" do + array = [False.new, False.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [False.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/grouping_test.rb b/activerecord/test/cases/arel/nodes/grouping_test.rb new file mode 100644 index 0000000000..03d5c142d5 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/grouping_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class GroupingTest < Arel::Spec + it "should create Equality nodes" do + grouping = Grouping.new(Nodes.build_quoted("foo")) + grouping.eq("foo").to_sql.must_be_like "('foo') = 'foo'" + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Grouping.new("foo"), Grouping.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Grouping.new("foo"), Grouping.new("bar")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/infix_operation_test.rb b/activerecord/test/cases/arel/nodes/infix_operation_test.rb new file mode 100644 index 0000000000..dcf2200c12 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/infix_operation_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestInfixOperation < Arel::Test + def test_construct + operation = InfixOperation.new :+, 1, 2 + assert_equal :+, operation.operator + assert_equal 1, operation.left + assert_equal 2, operation.right + end + + def test_operation_alias + operation = InfixOperation.new :+, 1, 2 + aliaz = operation.as("zomg") + assert_kind_of As, aliaz + assert_equal operation, aliaz.left + assert_equal "zomg", aliaz.right + end + + def test_operation_ordering + operation = InfixOperation.new :+, 1, 2 + ordering = operation.desc + assert_kind_of Descending, ordering + assert_equal operation, ordering.expr + assert ordering.descending? + end + + def test_equality_with_same_ivars + array = [InfixOperation.new(:+, 1, 2), InfixOperation.new(:+, 1, 2)] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [InfixOperation.new(:+, 1, 2), InfixOperation.new(:+, 1, 3)] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/insert_statement_test.rb b/activerecord/test/cases/arel/nodes/insert_statement_test.rb new file mode 100644 index 0000000000..252a0d0d0b --- /dev/null +++ b/activerecord/test/cases/arel/nodes/insert_statement_test.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::InsertStatement do + describe "#clone" do + it "clones columns and values" do + statement = Arel::Nodes::InsertStatement.new + statement.columns = %w[a b c] + statement.values = %w[x y z] + + dolly = statement.clone + dolly.columns.must_equal statement.columns + dolly.values.must_equal statement.values + + dolly.columns.wont_be_same_as statement.columns + dolly.values.wont_be_same_as statement.values + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::InsertStatement.new + statement1.columns = %w[a b c] + statement1.values = %w[x y z] + statement2 = Arel::Nodes::InsertStatement.new + statement2.columns = %w[a b c] + statement2.values = %w[x y z] + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::InsertStatement.new + statement1.columns = %w[a b c] + statement1.values = %w[x y z] + statement2 = Arel::Nodes::InsertStatement.new + statement2.columns = %w[a b c] + statement2.values = %w[1 2 3] + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/named_function_test.rb b/activerecord/test/cases/arel/nodes/named_function_test.rb new file mode 100644 index 0000000000..dbd7ae43be --- /dev/null +++ b/activerecord/test/cases/arel/nodes/named_function_test.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestNamedFunction < Arel::Test + def test_construct + function = NamedFunction.new "omg", "zomg" + assert_equal "omg", function.name + assert_equal "zomg", function.expressions + end + + def test_function_alias + function = NamedFunction.new "omg", "zomg" + function = function.as("wth") + assert_equal "omg", function.name + assert_equal "zomg", function.expressions + assert_kind_of SqlLiteral, function.alias + assert_equal "wth", function.alias + end + + def test_construct_with_alias + function = NamedFunction.new "omg", "zomg", "wth" + assert_equal "omg", function.name + assert_equal "zomg", function.expressions + assert_kind_of SqlLiteral, function.alias + assert_equal "wth", function.alias + end + + def test_equality_with_same_ivars + array = [ + NamedFunction.new("omg", "zomg", "wth"), + NamedFunction.new("omg", "zomg", "wth") + ] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [ + NamedFunction.new("omg", "zomg", "wth"), + NamedFunction.new("zomg", "zomg", "wth") + ] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/node_test.rb b/activerecord/test/cases/arel/nodes/node_test.rb new file mode 100644 index 0000000000..f4f07ef2c5 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/node_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + class TestNode < Arel::Test + def test_includes_factory_methods + assert Node.new.respond_to?(:create_join) + end + + def test_all_nodes_are_nodes + Nodes.constants.map { |k| + Nodes.const_get(k) + }.grep(Class).each do |klass| + next if Nodes::SqlLiteral == klass + next if Nodes::BindParam == klass + next if klass.name =~ /^Arel::Nodes::(?:Test|.*Test$)/ + assert klass.ancestors.include?(Nodes::Node), klass.name + end + end + + def test_each + list = [] + node = Nodes::Node.new + node.each { |n| list << n } + assert_equal [node], list + end + + def test_generator + list = [] + node = Nodes::Node.new + node.each.each { |n| list << n } + assert_equal [node], list + end + + def test_enumerable + node = Nodes::Node.new + assert_kind_of Enumerable, node + end + end +end diff --git a/activerecord/test/cases/arel/nodes/not_test.rb b/activerecord/test/cases/arel/nodes/not_test.rb new file mode 100644 index 0000000000..481e678700 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/not_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "not" do + describe "#not" do + it "makes a NOT node" do + attr = Table.new(:users)[:id] + expr = attr.eq(10) + node = expr.not + node.must_be_kind_of Not + node.expr.must_equal expr + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Not.new("foo"), Not.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Not.new("foo"), Not.new("baz")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/or_test.rb b/activerecord/test/cases/arel/nodes/or_test.rb new file mode 100644 index 0000000000..93f826740d --- /dev/null +++ b/activerecord/test/cases/arel/nodes/or_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "or" do + describe "#or" do + it "makes an OR node" do + attr = Table.new(:users)[:id] + left = attr.eq(10) + right = attr.eq(11) + node = left.or right + node.expr.left.must_equal left + node.expr.right.must_equal right + + oror = node.or(right) + oror.expr.left.must_equal node + oror.expr.right.must_equal right + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Or.new("foo", "bar"), Or.new("foo", "bar")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Or.new("foo", "bar"), Or.new("foo", "baz")] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/over_test.rb b/activerecord/test/cases/arel/nodes/over_test.rb new file mode 100644 index 0000000000..981ec2e34b --- /dev/null +++ b/activerecord/test/cases/arel/nodes/over_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::OverTest < Arel::Spec + describe "as" do + it "should alias the expression" do + table = Arel::Table.new :users + table[:id].count.over.as("foo").to_sql.must_be_like %{ + COUNT("users"."id") OVER () AS foo + } + end + end + + describe "with literal" do + it "should reference the window definition by name" do + table = Arel::Table.new :users + table[:id].count.over("foo").to_sql.must_be_like %{ + COUNT("users"."id") OVER "foo" + } + end + end + + describe "with SQL literal" do + it "should reference the window definition by name" do + table = Arel::Table.new :users + table[:id].count.over(Arel.sql("foo")).to_sql.must_be_like %{ + COUNT("users"."id") OVER foo + } + end + end + + describe "with no expression" do + it "should use empty definition" do + table = Arel::Table.new :users + table[:id].count.over.to_sql.must_be_like %{ + COUNT("users"."id") OVER () + } + end + end + + describe "with expression" do + it "should use definition in sub-expression" do + table = Arel::Table.new :users + window = Arel::Nodes::Window.new.order(table["foo"]) + table[:id].count.over(window).to_sql.must_be_like %{ + COUNT("users"."id") OVER (ORDER BY \"users\".\"foo\") + } + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [ + Arel::Nodes::Over.new("foo", "bar"), + Arel::Nodes::Over.new("foo", "bar") + ] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [ + Arel::Nodes::Over.new("foo", "bar"), + Arel::Nodes::Over.new("foo", "baz") + ] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/select_core_test.rb b/activerecord/test/cases/arel/nodes/select_core_test.rb new file mode 100644 index 0000000000..0b698205ff --- /dev/null +++ b/activerecord/test/cases/arel/nodes/select_core_test.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestSelectCore < Arel::Test + def test_clone + core = Arel::Nodes::SelectCore.new + core.froms = %w[a b c] + core.projections = %w[d e f] + core.wheres = %w[g h i] + + dolly = core.clone + + assert_equal core.froms, dolly.froms + assert_equal core.projections, dolly.projections + assert_equal core.wheres, dolly.wheres + + assert_not_same core.froms, dolly.froms + assert_not_same core.projections, dolly.projections + assert_not_same core.wheres, dolly.wheres + end + + def test_set_quantifier + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::Distinct.new + viz = Arel::Visitors::ToSql.new Table.engine.connection_pool + assert_match "DISTINCT", viz.accept(core, Collectors::SQLString.new).value + end + + def test_equality_with_same_ivars + core1 = SelectCore.new + core1.froms = %w[a b c] + core1.projections = %w[d e f] + core1.wheres = %w[g h i] + core1.groups = %w[j k l] + core1.windows = %w[m n o] + core1.havings = %w[p q r] + core2 = SelectCore.new + core2.froms = %w[a b c] + core2.projections = %w[d e f] + core2.wheres = %w[g h i] + core2.groups = %w[j k l] + core2.windows = %w[m n o] + core2.havings = %w[p q r] + array = [core1, core2] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + core1 = SelectCore.new + core1.froms = %w[a b c] + core1.projections = %w[d e f] + core1.wheres = %w[g h i] + core1.groups = %w[j k l] + core1.windows = %w[m n o] + core1.havings = %w[p q r] + core2 = SelectCore.new + core2.froms = %w[a b c] + core2.projections = %w[d e f] + core2.wheres = %w[g h i] + core2.groups = %w[j k l] + core2.windows = %w[m n o] + core2.havings = %w[l o l] + array = [core1, core2] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/select_statement_test.rb b/activerecord/test/cases/arel/nodes/select_statement_test.rb new file mode 100644 index 0000000000..a91605de3e --- /dev/null +++ b/activerecord/test/cases/arel/nodes/select_statement_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::SelectStatement do + describe "#clone" do + it "clones cores" do + statement = Arel::Nodes::SelectStatement.new %w[a b c] + + dolly = statement.clone + dolly.cores.must_equal statement.cores + dolly.cores.wont_be_same_as statement.cores + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::SelectStatement.new %w[a b c] + statement1.offset = 1 + statement1.limit = 2 + statement1.lock = false + statement1.orders = %w[x y z] + statement1.with = "zomg" + statement2 = Arel::Nodes::SelectStatement.new %w[a b c] + statement2.offset = 1 + statement2.limit = 2 + statement2.lock = false + statement2.orders = %w[x y z] + statement2.with = "zomg" + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::SelectStatement.new %w[a b c] + statement1.offset = 1 + statement1.limit = 2 + statement1.lock = false + statement1.orders = %w[x y z] + statement1.with = "zomg" + statement2 = Arel::Nodes::SelectStatement.new %w[a b c] + statement2.offset = 1 + statement2.limit = 2 + statement2.lock = false + statement2.orders = %w[x y z] + statement2.with = "wth" + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/sql_literal_test.rb b/activerecord/test/cases/arel/nodes/sql_literal_test.rb new file mode 100644 index 0000000000..3b95fed1f4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/sql_literal_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "yaml" + +module Arel + module Nodes + class SqlLiteralTest < Arel::Spec + before do + @visitor = Visitors::ToSql.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + describe "sql" do + it "makes a sql literal node" do + sql = Arel.sql "foo" + sql.must_be_kind_of Arel::Nodes::SqlLiteral + end + end + + describe "count" do + it "makes a count node" do + node = SqlLiteral.new("*").count + compile(node).must_be_like %{ COUNT(*) } + end + + it "makes a distinct node" do + node = SqlLiteral.new("*").count true + compile(node).must_be_like %{ COUNT(DISTINCT *) } + end + end + + describe "equality" do + it "makes an equality node" do + node = SqlLiteral.new("foo").eq(1) + compile(node).must_be_like %{ foo = 1 } + end + + it "is equal with equal contents" do + array = [SqlLiteral.new("foo"), SqlLiteral.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different contents" do + array = [SqlLiteral.new("foo"), SqlLiteral.new("bar")] + assert_equal 2, array.uniq.size + end + end + + describe 'grouped "or" equality' do + it "makes a grouping node with an or node" do + node = SqlLiteral.new("foo").eq_any([1, 2]) + compile(node).must_be_like %{ (foo = 1 OR foo = 2) } + end + end + + describe 'grouped "and" equality' do + it "makes a grouping node with an and node" do + node = SqlLiteral.new("foo").eq_all([1, 2]) + compile(node).must_be_like %{ (foo = 1 AND foo = 2) } + end + end + + describe "serialization" do + it "serializes into YAML" do + yaml_literal = SqlLiteral.new("foo").to_yaml + assert_equal("foo", YAML.load(yaml_literal)) + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/sum_test.rb b/activerecord/test/cases/arel/nodes/sum_test.rb new file mode 100644 index 0000000000..5015964951 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/sum_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require_relative "../helper" + +class Arel::Nodes::SumTest < Arel::Spec + describe "as" do + it "should alias the sum" do + table = Arel::Table.new :users + table[:id].sum.as("foo").to_sql.must_be_like %{ + SUM("users"."id") AS foo + } + end + end + + describe "equality" do + it "is equal with equal ivars" do + array = [Arel::Nodes::Sum.new("foo"), Arel::Nodes::Sum.new("foo")] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + array = [Arel::Nodes::Sum.new("foo"), Arel::Nodes::Sum.new("foo!")] + assert_equal 2, array.uniq.size + end + end + + describe "order" do + it "should order the sum" do + table = Arel::Table.new :users + table[:id].sum.desc.to_sql.must_be_like %{ + SUM("users"."id") DESC + } + end + end +end diff --git a/activerecord/test/cases/arel/nodes/table_alias_test.rb b/activerecord/test/cases/arel/nodes/table_alias_test.rb new file mode 100644 index 0000000000..c661b6771e --- /dev/null +++ b/activerecord/test/cases/arel/nodes/table_alias_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "table alias" do + describe "equality" do + it "is equal with equal ivars" do + relation1 = Table.new(:users) + node1 = TableAlias.new relation1, :foo + relation2 = Table.new(:users) + node2 = TableAlias.new relation2, :foo + array = [node1, node2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + relation1 = Table.new(:users) + node1 = TableAlias.new relation1, :foo + relation2 = Table.new(:users) + node2 = TableAlias.new relation2, :bar + array = [node1, node2] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/true_test.rb b/activerecord/test/cases/arel/nodes/true_test.rb new file mode 100644 index 0000000000..1e85fe7d48 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/true_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "True" do + describe "equality" do + it "is equal to other true nodes" do + array = [True.new, True.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [True.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/unary_operation_test.rb b/activerecord/test/cases/arel/nodes/unary_operation_test.rb new file mode 100644 index 0000000000..f0dd0c625c --- /dev/null +++ b/activerecord/test/cases/arel/nodes/unary_operation_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + class TestUnaryOperation < Arel::Test + def test_construct + operation = UnaryOperation.new :-, 1 + assert_equal :-, operation.operator + assert_equal 1, operation.expr + end + + def test_operation_alias + operation = UnaryOperation.new :-, 1 + aliaz = operation.as("zomg") + assert_kind_of As, aliaz + assert_equal operation, aliaz.left + assert_equal "zomg", aliaz.right + end + + def test_operation_ordering + operation = UnaryOperation.new :-, 1 + ordering = operation.desc + assert_kind_of Descending, ordering + assert_equal operation, ordering.expr + assert ordering.descending? + end + + def test_equality_with_same_ivars + array = [UnaryOperation.new(:-, 1), UnaryOperation.new(:-, 1)] + assert_equal 1, array.uniq.size + end + + def test_inequality_with_different_ivars + array = [UnaryOperation.new(:-, 1), UnaryOperation.new(:-, 2)] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes/update_statement_test.rb b/activerecord/test/cases/arel/nodes/update_statement_test.rb new file mode 100644 index 0000000000..a83ce32f68 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/update_statement_test.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require_relative "../helper" + +describe Arel::Nodes::UpdateStatement do + describe "#clone" do + it "clones wheres and values" do + statement = Arel::Nodes::UpdateStatement.new + statement.wheres = %w[a b c] + statement.values = %w[x y z] + + dolly = statement.clone + dolly.wheres.must_equal statement.wheres + dolly.wheres.wont_be_same_as statement.wheres + + dolly.values.must_equal statement.values + dolly.values.wont_be_same_as statement.values + end + end + + describe "equality" do + it "is equal with equal ivars" do + statement1 = Arel::Nodes::UpdateStatement.new + statement1.relation = "zomg" + statement1.wheres = 2 + statement1.values = false + statement1.orders = %w[x y z] + statement1.limit = 42 + statement1.key = "zomg" + statement2 = Arel::Nodes::UpdateStatement.new + statement2.relation = "zomg" + statement2.wheres = 2 + statement2.values = false + statement2.orders = %w[x y z] + statement2.limit = 42 + statement2.key = "zomg" + array = [statement1, statement2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + statement1 = Arel::Nodes::UpdateStatement.new + statement1.relation = "zomg" + statement1.wheres = 2 + statement1.values = false + statement1.orders = %w[x y z] + statement1.limit = 42 + statement1.key = "zomg" + statement2 = Arel::Nodes::UpdateStatement.new + statement2.relation = "zomg" + statement2.wheres = 2 + statement2.values = false + statement2.orders = %w[x y z] + statement2.limit = 42 + statement2.key = "wth" + array = [statement1, statement2] + assert_equal 2, array.uniq.size + end + end +end diff --git a/activerecord/test/cases/arel/nodes/window_test.rb b/activerecord/test/cases/arel/nodes/window_test.rb new file mode 100644 index 0000000000..729b0556a4 --- /dev/null +++ b/activerecord/test/cases/arel/nodes/window_test.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Nodes + describe "Window" do + describe "equality" do + it "is equal with equal ivars" do + window1 = Window.new + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = Window.new + window2.orders = [1, 2] + window2.partitions = [1] + window2.frame 3 + array = [window1, window2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + window1 = Window.new + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = Window.new + window2.orders = [1, 2] + window1.partitions = [1] + window2.frame 4 + array = [window1, window2] + assert_equal 2, array.uniq.size + end + end + end + + describe "NamedWindow" do + describe "equality" do + it "is equal with equal ivars" do + window1 = NamedWindow.new "foo" + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = NamedWindow.new "foo" + window2.orders = [1, 2] + window2.partitions = [1] + window2.frame 3 + array = [window1, window2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + window1 = NamedWindow.new "foo" + window1.orders = [1, 2] + window1.partitions = [1] + window1.frame 3 + window2 = NamedWindow.new "bar" + window2.orders = [1, 2] + window2.partitions = [1] + window2.frame 3 + array = [window1, window2] + assert_equal 2, array.uniq.size + end + end + end + + describe "CurrentRow" do + describe "equality" do + it "is equal to other current row nodes" do + array = [CurrentRow.new, CurrentRow.new] + assert_equal 1, array.uniq.size + end + + it "is not equal with other nodes" do + array = [CurrentRow.new, Node.new] + assert_equal 2, array.uniq.size + end + end + end + end +end diff --git a/activerecord/test/cases/arel/nodes_test.rb b/activerecord/test/cases/arel/nodes_test.rb new file mode 100644 index 0000000000..9021de0d20 --- /dev/null +++ b/activerecord/test/cases/arel/nodes_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + module Nodes + class TestNodes < Arel::Test + def test_every_arel_nodes_have_hash_eql_eqeq_from_same_class + # #descendants code from activesupport + node_descendants = [] + ObjectSpace.each_object(Arel::Nodes::Node.singleton_class) do |k| + next if k.respond_to?(:singleton_class?) && k.singleton_class? + node_descendants.unshift k unless k == self + end + node_descendants.delete(Arel::Nodes::Node) + node_descendants.delete(Arel::Nodes::NodeExpression) + + bad_node_descendants = node_descendants.reject do |subnode| + eqeq_owner = subnode.instance_method(:==).owner + eql_owner = subnode.instance_method(:eql?).owner + hash_owner = subnode.instance_method(:hash).owner + + eqeq_owner < Arel::Nodes::Node && + eqeq_owner == eql_owner && + eqeq_owner == hash_owner + end + + problem_msg = "Some subclasses of Arel::Nodes::Node do not have a" \ + " #== or #eql? or #hash defined from the same class as the others" + assert_empty bad_node_descendants, problem_msg + end + end + end +end diff --git a/activerecord/test/cases/arel/select_manager_test.rb b/activerecord/test/cases/arel/select_manager_test.rb new file mode 100644 index 0000000000..5220950905 --- /dev/null +++ b/activerecord/test/cases/arel/select_manager_test.rb @@ -0,0 +1,1225 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class SelectManagerTest < Arel::Spec + def test_join_sources + manager = Arel::SelectManager.new + manager.join_sources << Arel::Nodes::StringJoin.new(Nodes.build_quoted("foo")) + assert_equal "SELECT FROM 'foo'", manager.to_sql + end + + describe "backwards compatibility" do + describe "project" do + it "accepts symbols as sql literals" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project :id + manager.from table + manager.to_sql.must_be_like %{ + SELECT id FROM "users" + } + end + end + + describe "order" do + it "accepts symbols" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order :foo + manager.to_sql.must_be_like %{ SELECT * FROM "users" ORDER BY foo } + end + end + + describe "group" do + it "takes a symbol" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group :foo + manager.to_sql.must_be_like %{ SELECT FROM "users" GROUP BY foo } + end + end + + describe "as" do + it "makes an AS node by grouping the AST" do + manager = Arel::SelectManager.new + as = manager.as(Arel.sql("foo")) + assert_kind_of Arel::Nodes::Grouping, as.left + assert_equal manager.ast, as.left.expr + assert_equal "foo", as.right + end + + it "converts right to SqlLiteral if a string" do + manager = Arel::SelectManager.new + as = manager.as("foo") + assert_kind_of Arel::Nodes::SqlLiteral, as.right + end + + it "can make a subselect" do + manager = Arel::SelectManager.new + manager.project Arel.star + manager.from Arel.sql("zomg") + as = manager.as(Arel.sql("foo")) + + manager = Arel::SelectManager.new + manager.project Arel.sql("name") + manager.from as + manager.to_sql.must_be_like "SELECT name FROM (SELECT * FROM zomg) foo" + end + end + + describe "from" do + it "ignores strings when table of same name exists" do + table = Table.new :users + manager = Arel::SelectManager.new + + manager.from table + manager.from "users" + manager.project table["id"] + manager.to_sql.must_be_like 'SELECT "users"."id" FROM users' + end + + it "should support any ast" do + table = Table.new :users + manager1 = Arel::SelectManager.new + + manager2 = Arel::SelectManager.new + manager2.project(Arel.sql("*")) + manager2.from table + + manager1.project Arel.sql("lol") + as = manager2.as Arel.sql("omg") + manager1.from(as) + + manager1.to_sql.must_be_like %{ + SELECT lol FROM (SELECT * FROM "users") omg + } + end + end + + describe "having" do + it "converts strings to SQLLiterals" do + table = Table.new :users + mgr = table.from + mgr.having Arel.sql("foo") + mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo } + end + + it "can have multiple items specified separately" do + table = Table.new :users + mgr = table.from + mgr.having Arel.sql("foo") + mgr.having Arel.sql("bar") + mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo AND bar } + end + + it "can receive any node" do + table = Table.new :users + mgr = table.from + mgr.having Arel::Nodes::And.new([Arel.sql("foo"), Arel.sql("bar")]) + mgr.to_sql.must_be_like %{ SELECT FROM "users" HAVING foo AND bar } + end + end + + describe "on" do + it "converts to sqlliterals" do + table = Table.new :users + right = table.alias + mgr = table.from + mgr.join(right).on("omg") + mgr.to_sql.must_be_like %{ SELECT FROM "users" INNER JOIN "users" "users_2" ON omg } + end + + it "converts to sqlliterals with multiple items" do + table = Table.new :users + right = table.alias + mgr = table.from + mgr.join(right).on("omg", "123") + mgr.to_sql.must_be_like %{ SELECT FROM "users" INNER JOIN "users" "users_2" ON omg AND 123 } + end + end + end + + describe "clone" do + it "creates new cores" do + table = Table.new :users, as: "foo" + mgr = table.from + m2 = mgr.clone + m2.project "foo" + mgr.to_sql.wont_equal m2.to_sql + end + + it "makes updates to the correct copy" do + table = Table.new :users, as: "foo" + mgr = table.from + m2 = mgr.clone + m3 = m2.clone + m2.project "foo" + mgr.to_sql.wont_equal m2.to_sql + m3.to_sql.must_equal mgr.to_sql + end + end + + describe "initialize" do + it "uses alias in sql" do + table = Table.new :users, as: "foo" + mgr = table.from + mgr.skip 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" "foo" OFFSET 10 } + end + end + + describe "skip" do + it "should add an offset" do + table = Table.new :users + mgr = table.from + mgr.skip 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + end + + it "should chain" do + table = Table.new :users + mgr = table.from + mgr.skip(10).to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + end + end + + describe "offset" do + it "should add an offset" do + table = Table.new :users + mgr = table.from + mgr.offset = 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + end + + it "should remove an offset" do + table = Table.new :users + mgr = table.from + mgr.offset = 10 + mgr.to_sql.must_be_like %{ SELECT FROM "users" OFFSET 10 } + + mgr.offset = nil + mgr.to_sql.must_be_like %{ SELECT FROM "users" } + end + + it "should return the offset" do + table = Table.new :users + mgr = table.from + mgr.offset = 10 + assert_equal 10, mgr.offset + end + end + + describe "exists" do + it "should create an exists clause" do + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.project Nodes::SqlLiteral.new "*" + m2 = Arel::SelectManager.new + m2.project manager.exists + m2.to_sql.must_be_like %{ SELECT EXISTS (#{manager.to_sql}) } + end + + it "can be aliased" do + table = Table.new(:users) + manager = Arel::SelectManager.new table + manager.project Nodes::SqlLiteral.new "*" + m2 = Arel::SelectManager.new + m2.project manager.exists.as("foo") + m2.to_sql.must_be_like %{ SELECT EXISTS (#{manager.to_sql}) AS foo } + end + end + + describe "union" do + before do + table = Table.new :users + @m1 = Arel::SelectManager.new table + @m1.project Arel.star + @m1.where(table[:age].lt(18)) + + @m2 = Arel::SelectManager.new table + @m2.project Arel.star + @m2.where(table[:age].gt(99)) + end + + it "should union two managers" do + # FIXME should this union "managers" or "statements" ? + # FIXME this probably shouldn't return a node + node = @m1.union @m2 + + # maybe FIXME: decide when wrapper parens are needed + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" < 18 UNION SELECT * FROM "users" WHERE "users"."age" > 99 ) + } + end + + it "should union all" do + node = @m1.union :all, @m2 + + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" < 18 UNION ALL SELECT * FROM "users" WHERE "users"."age" > 99 ) + } + end + end + + describe "intersect" do + before do + table = Table.new :users + @m1 = Arel::SelectManager.new table + @m1.project Arel.star + @m1.where(table[:age].gt(18)) + + @m2 = Arel::SelectManager.new table + @m2.project Arel.star + @m2.where(table[:age].lt(99)) + end + + it "should intersect two managers" do + # FIXME should this intersect "managers" or "statements" ? + # FIXME this probably shouldn't return a node + node = @m1.intersect @m2 + + # maybe FIXME: decide when wrapper parens are needed + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" > 18 INTERSECT SELECT * FROM "users" WHERE "users"."age" < 99 ) + } + end + end + + describe "except" do + before do + table = Table.new :users + @m1 = Arel::SelectManager.new table + @m1.project Arel.star + @m1.where(table[:age].between(18..60)) + + @m2 = Arel::SelectManager.new table + @m2.project Arel.star + @m2.where(table[:age].between(40..99)) + end + + it "should except two managers" do + # FIXME should this except "managers" or "statements" ? + # FIXME this probably shouldn't return a node + node = @m1.except @m2 + + # maybe FIXME: decide when wrapper parens are needed + node.to_sql.must_be_like %{ + ( SELECT * FROM "users" WHERE "users"."age" BETWEEN 18 AND 60 EXCEPT SELECT * FROM "users" WHERE "users"."age" BETWEEN 40 AND 99 ) + } + end + end + + describe "with" do + it "should support basic WITH" do + users = Table.new(:users) + users_top = Table.new(:users_top) + comments = Table.new(:comments) + + top = users.project(users[:id]).where(users[:karma].gt(100)) + users_as = Arel::Nodes::As.new(users_top, top) + select_manager = comments.project(Arel.star).with(users_as) + .where(comments[:author_id].in(users_top.project(users_top[:id]))) + + select_manager.to_sql.must_be_like %{ + WITH "users_top" AS (SELECT "users"."id" FROM "users" WHERE "users"."karma" > 100) SELECT * FROM "comments" WHERE "comments"."author_id" IN (SELECT "users_top"."id" FROM "users_top") + } + end + + it "should support WITH RECURSIVE" do + comments = Table.new(:comments) + comments_id = comments[:id] + comments_parent_id = comments[:parent_id] + + replies = Table.new(:replies) + replies_id = replies[:id] + + recursive_term = Arel::SelectManager.new + recursive_term.from(comments).project(comments_id, comments_parent_id).where(comments_id.eq 42) + + non_recursive_term = Arel::SelectManager.new + non_recursive_term.from(comments).project(comments_id, comments_parent_id).join(replies).on(comments_parent_id.eq replies_id) + + union = recursive_term.union(non_recursive_term) + + as_statement = Arel::Nodes::As.new replies, union + + manager = Arel::SelectManager.new + manager.with(:recursive, as_statement).from(replies).project(Arel.star) + + sql = manager.to_sql + sql.must_be_like %{ + WITH RECURSIVE "replies" AS ( + SELECT "comments"."id", "comments"."parent_id" FROM "comments" WHERE "comments"."id" = 42 + UNION + SELECT "comments"."id", "comments"."parent_id" FROM "comments" INNER JOIN "replies" ON "comments"."parent_id" = "replies"."id" + ) + SELECT * FROM "replies" + } + end + end + + describe "ast" do + it "should return the ast" do + table = Table.new :users + mgr = table.from + assert mgr.ast + end + + it "should allow orders to work when the ast is grepped" do + table = Table.new :users + mgr = table.from + mgr.project Arel.sql "*" + mgr.from table + mgr.orders << Arel::Nodes::Ascending.new(Arel.sql("foo")) + mgr.ast.grep(Arel::Nodes::OuterJoin) + mgr.to_sql.must_be_like %{ SELECT * FROM "users" ORDER BY foo ASC } + end + end + + describe "taken" do + it "should return limit" do + manager = Arel::SelectManager.new + manager.take 10 + manager.taken.must_equal 10 + end + end + + describe "lock" do + # This should fail on other databases + it "adds a lock node" do + table = Table.new :users + mgr = table.from + mgr.lock.to_sql.must_be_like %{ SELECT FROM "users" FOR UPDATE } + end + end + + describe "orders" do + it "returns order clauses" do + table = Table.new :users + manager = Arel::SelectManager.new + order = table[:id] + manager.order table[:id] + manager.orders.must_equal [order] + end + end + + describe "order" do + it "generates order clauses" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order table[:id] + manager.to_sql.must_be_like %{ + SELECT * FROM "users" ORDER BY "users"."id" + } + end + + # FIXME: I would like to deprecate this + it "takes *args" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order table[:id], table[:name] + manager.to_sql.must_be_like %{ + SELECT * FROM "users" ORDER BY "users"."id", "users"."name" + } + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.order(table[:id]).must_equal manager + end + + it "has order attributes" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.from table + manager.order table[:id].desc + manager.to_sql.must_be_like %{ + SELECT * FROM "users" ORDER BY "users"."id" DESC + } + end + end + + describe "on" do + it "takes two params" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right).on(predicate, predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" AND + "users"."id" = "users_2"."id" + } + end + + it "takes three params" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right).on( + predicate, + predicate, + left[:name].eq(right[:name]) + ) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" AND + "users"."id" = "users_2"."id" AND + "users"."name" = "users_2"."name" + } + end + end + + it "should hand back froms" do + relation = Arel::SelectManager.new + assert_equal [], relation.froms + end + + it "should create and nodes" do + relation = Arel::SelectManager.new + children = ["foo", "bar", "baz"] + clause = relation.create_and children + assert_kind_of Arel::Nodes::And, clause + assert_equal children, clause.children + end + + it "should create insert managers" do + relation = Arel::SelectManager.new + insert = relation.create_insert + assert_kind_of Arel::InsertManager, insert + end + + it "should create join nodes" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar" + assert_kind_of Arel::Nodes::InnerJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a full outer join klass" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar", Arel::Nodes::FullOuterJoin + assert_kind_of Arel::Nodes::FullOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a outer join klass" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar", Arel::Nodes::OuterJoin + assert_kind_of Arel::Nodes::OuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a right outer join klass" do + relation = Arel::SelectManager.new + join = relation.create_join "foo", "bar", Arel::Nodes::RightOuterJoin + assert_kind_of Arel::Nodes::RightOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + describe "join" do + it "responds to join" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "takes a class" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right, Nodes::OuterJoin).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "takes the full outer join class" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right, Nodes::FullOuterJoin).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + FULL OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "takes the right outer join class" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.join(right, Nodes::RightOuterJoin).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + RIGHT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "noops on nil" do + manager = Arel::SelectManager.new + manager.join(nil).must_equal manager + end + + it "raises EmptyJoinError on empty" do + left = Table.new :users + manager = Arel::SelectManager.new + + manager.from left + assert_raises(EmptyJoinError) do + manager.join("") + end + end + end + + describe "outer join" do + it "responds to join" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + manager = Arel::SelectManager.new + + manager.from left + manager.outer_join(right).on(predicate) + manager.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "noops on nil" do + manager = Arel::SelectManager.new + manager.outer_join(nil).must_equal manager + end + end + + describe "joins" do + it "returns inner join sql" do + table = Table.new :users + aliaz = table.alias + manager = Arel::SelectManager.new + manager.from Nodes::InnerJoin.new(aliaz, table[:id].eq(aliaz[:id])) + assert_match 'INNER JOIN "users" "users_2" "users"."id" = "users_2"."id"', + manager.to_sql + end + + it "returns outer join sql" do + table = Table.new :users + aliaz = table.alias + manager = Arel::SelectManager.new + manager.from Nodes::OuterJoin.new(aliaz, table[:id].eq(aliaz[:id])) + assert_match 'LEFT OUTER JOIN "users" "users_2" "users"."id" = "users_2"."id"', + manager.to_sql + end + + it "can have a non-table alias as relation name" do + users = Table.new :users + comments = Table.new :comments + + counts = comments.from. + group(comments[:user_id]). + project( + comments[:user_id].as("user_id"), + comments[:user_id].count.as("count") + ).as("counts") + + joins = users.join(counts).on(counts[:user_id].eq(10)) + joins.to_sql.must_be_like %{ + SELECT FROM "users" INNER JOIN (SELECT "comments"."user_id" AS user_id, COUNT("comments"."user_id") AS count FROM "comments" GROUP BY "comments"."user_id") counts ON counts."user_id" = 10 + } + end + + it "joins itself" do + left = Table.new :users + right = left.alias + predicate = left[:id].eq(right[:id]) + + mgr = left.join(right) + mgr.project Nodes::SqlLiteral.new("*") + mgr.on(predicate).must_equal mgr + + mgr.to_sql.must_be_like %{ + SELECT * FROM "users" + INNER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + + it "returns string join sql" do + manager = Arel::SelectManager.new + manager.from Nodes::StringJoin.new(Nodes.build_quoted("hello")) + assert_match "'hello'", manager.to_sql + end + end + + describe "group" do + it "takes an attribute" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group table[:id] + manager.to_sql.must_be_like %{ + SELECT FROM "users" GROUP BY "users"."id" + } + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.group(table[:id]).must_equal manager + end + + it "takes multiple args" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group table[:id], table[:name] + manager.to_sql.must_be_like %{ + SELECT FROM "users" GROUP BY "users"."id", "users"."name" + } + end + + # FIXME: backwards compat + it "makes strings literals" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.group "foo" + manager.to_sql.must_be_like %{ SELECT FROM "users" GROUP BY foo } + end + end + + describe "window definition" do + it "can be empty" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window") + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS () + } + end + + it "takes an order" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").order(table["foo"].asc) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ORDER BY "users"."foo" ASC) + } + end + + it "takes an order with multiple columns" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").order(table["foo"].asc, table["bar"].desc) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ORDER BY "users"."foo" ASC, "users"."bar" DESC) + } + end + + it "takes a partition" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").partition(table["bar"]) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."bar") + } + end + + it "takes a partition and an order" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").partition(table["foo"]).order(table["foo"].asc) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."foo" + ORDER BY "users"."foo" ASC) + } + end + + it "takes a partition with multiple columns" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").partition(table["bar"], table["baz"]) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (PARTITION BY "users"."bar", "users"."baz") + } + end + + it "takes a rows frame, unbounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Preceding.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS UNBOUNDED PRECEDING) + } + end + + it "takes a rows frame, bounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Preceding.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS 5 PRECEDING) + } + end + + it "takes a rows frame, unbounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Following.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS UNBOUNDED FOLLOWING) + } + end + + it "takes a rows frame, bounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::Following.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS 5 FOLLOWING) + } + end + + it "takes a rows frame, current row" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").rows(Arel::Nodes::CurrentRow.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS CURRENT ROW) + } + end + + it "takes a rows frame, between two delimiters" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + window = manager.window("a_window") + window.frame( + Arel::Nodes::Between.new( + window.rows, + Nodes::And.new([ + Arel::Nodes::Preceding.new, + Arel::Nodes::CurrentRow.new + ]))) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) + } + end + + it "takes a range frame, unbounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Preceding.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE UNBOUNDED PRECEDING) + } + end + + it "takes a range frame, bounded preceding" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Preceding.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE 5 PRECEDING) + } + end + + it "takes a range frame, unbounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Following.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE UNBOUNDED FOLLOWING) + } + end + + it "takes a range frame, bounded following" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::Following.new(5)) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE 5 FOLLOWING) + } + end + + it "takes a range frame, current row" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.window("a_window").range(Arel::Nodes::CurrentRow.new) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE CURRENT ROW) + } + end + + it "takes a range frame, between two delimiters" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + window = manager.window("a_window") + window.frame( + Arel::Nodes::Between.new( + window.range, + Nodes::And.new([ + Arel::Nodes::Preceding.new, + Arel::Nodes::CurrentRow.new + ]))) + manager.to_sql.must_be_like %{ + SELECT FROM "users" WINDOW "a_window" AS (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) + } + end + end + + describe "delete" do + it "copies from" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + stmt = manager.compile_delete + + stmt.to_sql.must_be_like %{ DELETE FROM "users" } + end + + it "copies where" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + stmt = manager.compile_delete + + stmt.to_sql.must_be_like %{ + DELETE FROM "users" WHERE "users"."id" = 10 + } + end + end + + describe "where_sql" do + it "gives me back the where sql" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 } + end + + it "joins wheres with AND" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + manager.where table[:id].eq 11 + manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 AND "users"."id" = 11} + end + + it "handles database specific statements" do + old_visitor = Table.engine.connection.visitor + Table.engine.connection.visitor = Visitors::PostgreSQL.new Table.engine.connection + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where table[:id].eq 10 + manager.where table[:name].matches "foo%" + manager.where_sql.must_be_like %{ WHERE "users"."id" = 10 AND "users"."name" ILIKE 'foo%' } + Table.engine.connection.visitor = old_visitor + end + + it "returns nil when there are no wheres" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.where_sql.must_be_nil + end + end + + describe "update" do + it "creates an update statement" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1 + } + end + + it "takes a string" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ UPDATE "users" SET foo = bar } + end + + it "copies limits" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.take 1 + stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id")) + stmt.key = table["id"] + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET foo = bar + WHERE "users"."id" IN (SELECT "users"."id" FROM "users" LIMIT 1) + } + end + + it "copies order" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from table + manager.order :foo + stmt = manager.compile_update(Nodes::SqlLiteral.new("foo = bar"), Arel::Attributes::Attribute.new(table, "id")) + stmt.key = table["id"] + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET foo = bar + WHERE "users"."id" IN (SELECT "users"."id" FROM "users" ORDER BY foo) + } + end + + it "copies where clauses" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.where table[:id].eq 10 + manager.from table + stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1 WHERE "users"."id" = 10 + } + end + + it "copies where clauses when nesting is triggered" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.where table[:foo].eq 10 + manager.take 42 + manager.from table + stmt = manager.compile_update({ table[:id] => 1 }, Arel::Attributes::Attribute.new(table, "id")) + + stmt.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1 WHERE "users"."id" IN (SELECT "users"."id" FROM "users" WHERE "users"."foo" = 10 LIMIT 42) + } + end + end + + describe "project" do + it "takes sql literals" do + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new "*" + manager.to_sql.must_be_like %{ SELECT * } + end + + it "takes multiple args" do + manager = Arel::SelectManager.new + manager.project Nodes::SqlLiteral.new("foo"), + Nodes::SqlLiteral.new("bar") + manager.to_sql.must_be_like %{ SELECT foo, bar } + end + + it "takes strings" do + manager = Arel::SelectManager.new + manager.project "*" + manager.to_sql.must_be_like %{ SELECT * } + end + end + + describe "projections" do + it "reads projections" do + manager = Arel::SelectManager.new + manager.project Arel.sql("foo"), Arel.sql("bar") + manager.projections.must_equal [Arel.sql("foo"), Arel.sql("bar")] + end + end + + describe "projections=" do + it "overwrites projections" do + manager = Arel::SelectManager.new + manager.project Arel.sql("foo") + manager.projections = [Arel.sql("bar")] + manager.to_sql.must_be_like %{ SELECT bar } + end + end + + describe "take" do + it "knows take" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table).project(table["id"]) + manager.where(table["id"].eq(1)) + manager.take 1 + + manager.to_sql.must_be_like %{ + SELECT "users"."id" + FROM "users" + WHERE "users"."id" = 1 + LIMIT 1 + } + end + + it "chains" do + manager = Arel::SelectManager.new + manager.take(1).must_equal manager + end + + it "removes LIMIT when nil is passed" do + manager = Arel::SelectManager.new + manager.limit = 10 + assert_match("LIMIT", manager.to_sql) + + manager.limit = nil + assert_no_match("LIMIT", manager.to_sql) + end + end + + describe "where" do + it "knows where" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table).project(table["id"]) + manager.where(table["id"].eq(1)) + manager.to_sql.must_be_like %{ + SELECT "users"."id" + FROM "users" + WHERE "users"."id" = 1 + } + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table) + manager.project(table["id"]).where(table["id"].eq 1).must_equal manager + end + end + + describe "from" do + it "makes sql" do + table = Table.new :users + manager = Arel::SelectManager.new + + manager.from table + manager.project table["id"] + manager.to_sql.must_be_like 'SELECT "users"."id" FROM "users"' + end + + it "chains" do + table = Table.new :users + manager = Arel::SelectManager.new + manager.from(table).project(table["id"]).must_equal manager + manager.to_sql.must_be_like 'SELECT "users"."id" FROM "users"' + end + end + + describe "source" do + it "returns the join source of the select core" do + manager = Arel::SelectManager.new + manager.source.must_equal manager.ast.cores.last.source + end + end + + describe "distinct" do + it "sets the quantifier" do + manager = Arel::SelectManager.new + + manager.distinct + manager.ast.cores.last.set_quantifier.class.must_equal Arel::Nodes::Distinct + + manager.distinct(false) + manager.ast.cores.last.set_quantifier.must_be_nil + end + + it "chains" do + manager = Arel::SelectManager.new + manager.distinct.must_equal manager + manager.distinct(false).must_equal manager + end + end + + describe "distinct_on" do + it "sets the quantifier" do + manager = Arel::SelectManager.new + table = Table.new :users + + manager.distinct_on(table["id"]) + manager.ast.cores.last.set_quantifier.must_equal Arel::Nodes::DistinctOn.new(table["id"]) + + manager.distinct_on(false) + manager.ast.cores.last.set_quantifier.must_be_nil + end + + it "chains" do + manager = Arel::SelectManager.new + table = Table.new :users + + manager.distinct_on(table["id"]).must_equal manager + manager.distinct_on(false).must_equal manager + end + end + end +end diff --git a/activerecord/test/cases/arel/support/fake_record.rb b/activerecord/test/cases/arel/support/fake_record.rb new file mode 100644 index 0000000000..559ff5d4e6 --- /dev/null +++ b/activerecord/test/cases/arel/support/fake_record.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "date" +module FakeRecord + class Column < Struct.new(:name, :type) + end + + class Connection + attr_reader :tables + attr_accessor :visitor + + def initialize(visitor = nil) + @tables = %w{ users photos developers products} + @columns = { + "users" => [ + Column.new("id", :integer), + Column.new("name", :string), + Column.new("bool", :boolean), + Column.new("created_at", :date) + ], + "products" => [ + Column.new("id", :integer), + Column.new("price", :decimal) + ] + } + @columns_hash = { + "users" => Hash[@columns["users"].map { |x| [x.name, x] }], + "products" => Hash[@columns["products"].map { |x| [x.name, x] }] + } + @primary_keys = { + "users" => "id", + "products" => "id" + } + @visitor = visitor + end + + def columns_hash(table_name) + @columns_hash[table_name] + end + + def primary_key(name) + @primary_keys[name.to_s] + end + + def data_source_exists?(name) + @tables.include? name.to_s + end + + def columns(name, message = nil) + @columns[name.to_s] + end + + def quote_table_name(name) + "\"#{name}\"" + end + + def quote_column_name(name) + "\"#{name}\"" + end + + def schema_cache + self + end + + def quote(thing) + case thing + when DateTime + "'#{thing.strftime("%Y-%m-%d %H:%M:%S")}'" + when Date + "'#{thing.strftime("%Y-%m-%d")}'" + when true + "'t'" + when false + "'f'" + when nil + "NULL" + when Numeric + thing + else + "'#{thing.to_s.gsub("'", "\\\\'")}'" + end + end + end + + class ConnectionPool + class Spec < Struct.new(:config) + end + + attr_reader :spec, :connection + + def initialize + @spec = Spec.new(adapter: "america") + @connection = Connection.new + @connection.visitor = Arel::Visitors::ToSql.new(connection) + end + + def with_connection + yield connection + end + + def table_exists?(name) + connection.tables.include? name.to_s + end + + def columns_hash + connection.columns_hash + end + + def schema_cache + connection + end + + def quote(thing) + connection.quote thing + end + end + + class Base + attr_accessor :connection_pool + + def initialize + @connection_pool = ConnectionPool.new + end + + def connection + connection_pool.connection + end + end +end diff --git a/activerecord/test/cases/arel/table_test.rb b/activerecord/test/cases/arel/table_test.rb new file mode 100644 index 0000000000..91b7a5a480 --- /dev/null +++ b/activerecord/test/cases/arel/table_test.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class TableTest < Arel::Spec + before do + @relation = Table.new(:users) + end + + it "should create join nodes" do + join = @relation.create_string_join "foo" + assert_kind_of Arel::Nodes::StringJoin, join + assert_equal "foo", join.left + end + + it "should create join nodes" do + join = @relation.create_join "foo", "bar" + assert_kind_of Arel::Nodes::InnerJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a klass" do + join = @relation.create_join "foo", "bar", Arel::Nodes::FullOuterJoin + assert_kind_of Arel::Nodes::FullOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a klass" do + join = @relation.create_join "foo", "bar", Arel::Nodes::OuterJoin + assert_kind_of Arel::Nodes::OuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should create join nodes with a klass" do + join = @relation.create_join "foo", "bar", Arel::Nodes::RightOuterJoin + assert_kind_of Arel::Nodes::RightOuterJoin, join + assert_equal "foo", join.left + assert_equal "bar", join.right + end + + it "should return an insert manager" do + im = @relation.compile_insert "VALUES(NULL)" + assert_kind_of Arel::InsertManager, im + im.into Table.new(:users) + assert_equal "INSERT INTO \"users\" VALUES(NULL)", im.to_sql + end + + describe "skip" do + it "should add an offset" do + sm = @relation.skip 2 + sm.to_sql.must_be_like "SELECT FROM \"users\" OFFSET 2" + end + end + + describe "having" do + it "adds a having clause" do + mgr = @relation.having @relation[:id].eq(10) + mgr.to_sql.must_be_like %{ + SELECT FROM "users" HAVING "users"."id" = 10 + } + end + end + + describe "backwards compat" do + describe "join" do + it "noops on nil" do + mgr = @relation.join nil + + mgr.to_sql.must_be_like %{ SELECT FROM "users" } + end + + it "raises EmptyJoinError on empty" do + assert_raises(EmptyJoinError) do + @relation.join "" + end + end + + it "takes a second argument for join type" do + right = @relation.alias + predicate = @relation[:id].eq(right[:id]) + mgr = @relation.join(right, Nodes::OuterJoin).on(predicate) + + mgr.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + end + + describe "join" do + it "creates an outer join" do + right = @relation.alias + predicate = @relation[:id].eq(right[:id]) + mgr = @relation.outer_join(right).on(predicate) + + mgr.to_sql.must_be_like %{ + SELECT FROM "users" + LEFT OUTER JOIN "users" "users_2" + ON "users"."id" = "users_2"."id" + } + end + end + end + + describe "group" do + it "should create a group" do + manager = @relation.group @relation[:id] + manager.to_sql.must_be_like %{ + SELECT FROM "users" GROUP BY "users"."id" + } + end + end + + describe "alias" do + it "should create a node that proxies to a table" do + node = @relation.alias + node.name.must_equal "users_2" + node[:id].relation.must_equal node + end + end + + describe "new" do + it "should accept a hash" do + rel = Table.new :users, as: "foo" + rel.table_alias.must_equal "foo" + end + + it "ignores as if it equals name" do + rel = Table.new :users, as: "users" + rel.table_alias.must_be_nil + end + end + + describe "order" do + it "should take an order" do + manager = @relation.order "foo" + manager.to_sql.must_be_like %{ SELECT FROM "users" ORDER BY foo } + end + end + + describe "take" do + it "should add a limit" do + manager = @relation.take 1 + manager.project Nodes::SqlLiteral.new "*" + manager.to_sql.must_be_like %{ SELECT * FROM "users" LIMIT 1 } + end + end + + describe "project" do + it "can project" do + manager = @relation.project Nodes::SqlLiteral.new "*" + manager.to_sql.must_be_like %{ SELECT * FROM "users" } + end + + it "takes multiple parameters" do + manager = @relation.project Nodes::SqlLiteral.new("*"), Nodes::SqlLiteral.new("*") + manager.to_sql.must_be_like %{ SELECT *, * FROM "users" } + end + end + + describe "where" do + it "returns a tree manager" do + manager = @relation.where @relation[:id].eq 1 + manager.project @relation[:id] + manager.must_be_kind_of TreeManager + manager.to_sql.must_be_like %{ + SELECT "users"."id" + FROM "users" + WHERE "users"."id" = 1 + } + end + end + + it "should have a name" do + @relation.name.must_equal "users" + end + + it "should have a table name" do + @relation.table_name.must_equal "users" + end + + describe "[]" do + describe "when given a Symbol" do + it "manufactures an attribute if the symbol names an attribute within the relation" do + column = @relation[:id] + column.name.must_equal :id + end + end + end + + describe "equality" do + it "is equal with equal ivars" do + relation1 = Table.new(:users) + relation1.table_alias = "zomg" + relation2 = Table.new(:users) + relation2.table_alias = "zomg" + array = [relation1, relation2] + assert_equal 1, array.uniq.size + end + + it "is not equal with different ivars" do + relation1 = Table.new(:users) + relation1.table_alias = "zomg" + relation2 = Table.new(:users) + relation2.table_alias = "zomg2" + array = [relation1, relation2] + assert_equal 2, array.uniq.size + end + end + end +end diff --git a/activerecord/test/cases/arel/update_manager_test.rb b/activerecord/test/cases/arel/update_manager_test.rb new file mode 100644 index 0000000000..cc1b9ac5b3 --- /dev/null +++ b/activerecord/test/cases/arel/update_manager_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require_relative "helper" + +module Arel + class UpdateManagerTest < Arel::Spec + describe "new" do + it "takes an engine" do + Arel::UpdateManager.new + end + end + + it "should not quote sql literals" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set [[table[:name], Arel::Nodes::BindParam.new(1)]] + um.to_sql.must_be_like %{ UPDATE "users" SET "name" = ? } + end + + it "handles limit properly" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.key = "id" + um.take 10 + um.table table + um.set [[table[:name], nil]] + assert_match(/LIMIT 10/, um.to_sql) + end + + describe "set" do + it "updates with null" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set [[table[:name], nil]] + um.to_sql.must_be_like %{ UPDATE "users" SET "name" = NULL } + end + + it "takes a string" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set Nodes::SqlLiteral.new "foo = bar" + um.to_sql.must_be_like %{ UPDATE "users" SET foo = bar } + end + + it "takes a list of lists" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.table table + um.set [[table[:id], 1], [table[:name], "hello"]] + um.to_sql.must_be_like %{ + UPDATE "users" SET "id" = 1, "name" = 'hello' + } + end + + it "chains" do + table = Table.new(:users) + um = Arel::UpdateManager.new + um.set([[table[:id], 1], [table[:name], "hello"]]).must_equal um + end + end + + describe "table" do + it "generates an update statement" do + um = Arel::UpdateManager.new + um.table Table.new(:users) + um.to_sql.must_be_like %{ UPDATE "users" } + end + + it "chains" do + um = Arel::UpdateManager.new + um.table(Table.new(:users)).must_equal um + end + + it "generates an update statement with joins" do + um = Arel::UpdateManager.new + + table = Table.new(:users) + join_source = Arel::Nodes::JoinSource.new( + table, + [table.create_join(Table.new(:posts))] + ) + + um.table join_source + um.to_sql.must_be_like %{ UPDATE "users" INNER JOIN "posts" } + end + end + + describe "where" do + it "generates a where clause" do + table = Table.new :users + um = Arel::UpdateManager.new + um.table table + um.where table[:id].eq(1) + um.to_sql.must_be_like %{ + UPDATE "users" WHERE "users"."id" = 1 + } + end + + it "chains" do + table = Table.new :users + um = Arel::UpdateManager.new + um.table table + um.where(table[:id].eq(1)).must_equal um + end + end + + describe "key" do + before do + @table = Table.new :users + @um = Arel::UpdateManager.new + @um.key = @table[:foo] + end + + it "can be set" do + @um.ast.key.must_equal @table[:foo] + end + + it "can be accessed" do + @um.key.must_equal @table[:foo] + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/depth_first_test.rb b/activerecord/test/cases/arel/visitors/depth_first_test.rb new file mode 100644 index 0000000000..f94ad521d7 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/depth_first_test.rb @@ -0,0 +1,270 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class TestDepthFirst < Arel::Test + Collector = Struct.new(:calls) do + def call(object) + calls << object + end + end + + def setup + @collector = Collector.new [] + @visitor = Visitors::DepthFirst.new @collector + end + + def test_raises_with_object + assert_raises(TypeError) do + @visitor.accept(Object.new) + end + end + + + # unary ops + [ + Arel::Nodes::Not, + Arel::Nodes::Group, + Arel::Nodes::On, + Arel::Nodes::Grouping, + Arel::Nodes::Offset, + Arel::Nodes::Ordering, + Arel::Nodes::StringJoin, + Arel::Nodes::UnqualifiedColumn, + Arel::Nodes::Limit, + Arel::Nodes::Else, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + op = klass.new(:a) + @visitor.accept op + assert_equal [:a, op], @collector.calls + end + end + + # functions + [ + Arel::Nodes::Exists, + Arel::Nodes::Avg, + Arel::Nodes::Min, + Arel::Nodes::Max, + Arel::Nodes::Sum, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + func = klass.new(:a, "b") + @visitor.accept func + assert_equal [:a, "b", false, func], @collector.calls + end + end + + def test_named_function + func = Arel::Nodes::NamedFunction.new(:a, :b, "c") + @visitor.accept func + assert_equal [:a, :b, false, "c", func], @collector.calls + end + + def test_lock + lock = Nodes::Lock.new true + @visitor.accept lock + assert_equal [lock], @collector.calls + end + + def test_count + count = Nodes::Count.new :a, :b, "c" + @visitor.accept count + assert_equal [:a, "c", :b, count], @collector.calls + end + + def test_inner_join + join = Nodes::InnerJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + def test_full_outer_join + join = Nodes::FullOuterJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + def test_outer_join + join = Nodes::OuterJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + def test_right_outer_join + join = Nodes::RightOuterJoin.new :a, :b + @visitor.accept join + assert_equal [:a, :b, join], @collector.calls + end + + [ + Arel::Nodes::Assignment, + Arel::Nodes::Between, + Arel::Nodes::Concat, + Arel::Nodes::DoesNotMatch, + Arel::Nodes::Equality, + Arel::Nodes::GreaterThan, + Arel::Nodes::GreaterThanOrEqual, + Arel::Nodes::In, + Arel::Nodes::LessThan, + Arel::Nodes::LessThanOrEqual, + Arel::Nodes::Matches, + Arel::Nodes::NotEqual, + Arel::Nodes::NotIn, + Arel::Nodes::Or, + Arel::Nodes::TableAlias, + Arel::Nodes::Values, + Arel::Nodes::As, + Arel::Nodes::DeleteStatement, + Arel::Nodes::JoinSource, + Arel::Nodes::When, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new(:a, :b) + @visitor.accept binary + assert_equal [:a, :b, binary], @collector.calls + end + end + + def test_Arel_Nodes_InfixOperation + binary = Arel::Nodes::InfixOperation.new(:o, :a, :b) + @visitor.accept binary + assert_equal [:a, :b, binary], @collector.calls + end + + # N-ary + [ + Arel::Nodes::And, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new([:a, :b, :c]) + @visitor.accept binary + assert_equal [:a, :b, :c, binary], @collector.calls + end + end + + [ + Arel::Attributes::Integer, + Arel::Attributes::Float, + Arel::Attributes::String, + Arel::Attributes::Time, + Arel::Attributes::Boolean, + Arel::Attributes::Attribute + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new(:a, :b) + @visitor.accept binary + assert_equal [:a, :b, binary], @collector.calls + end + end + + def test_table + relation = Arel::Table.new(:users) + @visitor.accept relation + assert_equal ["users", relation], @collector.calls + end + + def test_array + node = Nodes::Or.new(:a, :b) + list = [node] + @visitor.accept list + assert_equal [:a, :b, node, list], @collector.calls + end + + def test_set + node = Nodes::Or.new(:a, :b) + set = Set.new([node]) + @visitor.accept set + assert_equal [:a, :b, node, set], @collector.calls + end + + def test_hash + node = Nodes::Or.new(:a, :b) + hash = { node => node } + @visitor.accept hash + assert_equal [:a, :b, node, :a, :b, node, hash], @collector.calls + end + + def test_update_statement + stmt = Nodes::UpdateStatement.new + stmt.relation = :a + stmt.values << :b + stmt.wheres << :c + stmt.orders << :d + stmt.limit = :e + + @visitor.accept stmt + assert_equal [:a, :b, stmt.values, :c, stmt.wheres, :d, stmt.orders, + :e, stmt], @collector.calls + end + + def test_select_core + core = Nodes::SelectCore.new + core.projections << :a + core.froms = :b + core.wheres << :c + core.groups << :d + core.windows << :e + core.havings << :f + + @visitor.accept core + assert_equal [ + :a, core.projections, + :b, [], + core.source, + :c, core.wheres, + :d, core.groups, + :e, core.windows, + :f, core.havings, + core], @collector.calls + end + + def test_select_statement + ss = Nodes::SelectStatement.new + ss.cores.replace [:a] + ss.orders << :b + ss.limit = :c + ss.lock = :d + ss.offset = :e + + @visitor.accept ss + assert_equal [ + :a, ss.cores, + :b, ss.orders, + :c, + :d, + :e, + ss], @collector.calls + end + + def test_insert_statement + stmt = Nodes::InsertStatement.new + stmt.relation = :a + stmt.columns << :b + stmt.values = :c + + @visitor.accept stmt + assert_equal [:a, :b, stmt.columns, :c, stmt], @collector.calls + end + + def test_case + node = Arel::Nodes::Case.new + node.case = :a + node.conditions << :b + node.default = :c + + @visitor.accept node + assert_equal [:a, :b, node.conditions, :c, node], @collector.calls + end + + def test_node + node = Nodes::Node.new + @visitor.accept node + assert_equal [node], @collector.calls + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb b/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb new file mode 100644 index 0000000000..a07a1a050a --- /dev/null +++ b/activerecord/test/cases/arel/visitors/dispatch_contamination_test.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "concurrent" + +module Arel + module Visitors + class DummyVisitor < Visitor + def initialize + super + @barrier = Concurrent::CyclicBarrier.new(2) + end + + def visit_Arel_Visitors_DummySuperNode(node) + 42 + end + + # This is terrible, but it's the only way to reliably reproduce + # the possible race where two threads attempt to correct the + # dispatch hash at the same time. + def send(*args) + super + rescue + # Both threads try (and fail) to dispatch to the subclass's name + @barrier.wait + raise + ensure + # Then one thread successfully completes (updating the dispatch + # table in the process) before the other finishes raising its + # exception. + Thread.current[:delay].wait if Thread.current[:delay] + end + end + + class DummySuperNode + end + + class DummySubNode < DummySuperNode + end + + class DispatchContaminationTest < Arel::Spec + before do + @connection = Table.engine.connection + @table = Table.new(:users) + end + + it "dispatches properly after failing upwards" do + node = Nodes::Union.new(Nodes::True.new, Nodes::False.new) + assert_equal "( TRUE UNION FALSE )", node.to_sql + + node.first # from Nodes::Node's Enumerable mixin + + assert_equal "( TRUE UNION FALSE )", node.to_sql + end + + it "is threadsafe when implementing superclass fallback" do + visitor = DummyVisitor.new + main_thread_finished = Concurrent::Event.new + + racing_thread = Thread.new do + Thread.current[:delay] = main_thread_finished + visitor.accept DummySubNode.new + end + + assert_equal 42, visitor.accept(DummySubNode.new) + main_thread_finished.set + + assert_equal 42, racing_thread.value + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/dot_test.rb b/activerecord/test/cases/arel/visitors/dot_test.rb new file mode 100644 index 0000000000..6b3c132f83 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/dot_test.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class TestDot < Arel::Test + def setup + @visitor = Visitors::Dot.new + end + + # functions + [ + Nodes::Sum, + Nodes::Exists, + Nodes::Max, + Nodes::Min, + Nodes::Avg, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + op = klass.new(:a, "z") + @visitor.accept op, Collectors::PlainString.new + end + end + + def test_named_function + func = Nodes::NamedFunction.new "omg", "omg" + @visitor.accept func, Collectors::PlainString.new + end + + # unary ops + [ + Arel::Nodes::Not, + Arel::Nodes::Group, + Arel::Nodes::On, + Arel::Nodes::Grouping, + Arel::Nodes::Offset, + Arel::Nodes::Ordering, + Arel::Nodes::UnqualifiedColumn, + Arel::Nodes::Limit, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + op = klass.new(:a) + @visitor.accept op, Collectors::PlainString.new + end + end + + # binary ops + [ + Arel::Nodes::Assignment, + Arel::Nodes::Between, + Arel::Nodes::DoesNotMatch, + Arel::Nodes::Equality, + Arel::Nodes::GreaterThan, + Arel::Nodes::GreaterThanOrEqual, + Arel::Nodes::In, + Arel::Nodes::LessThan, + Arel::Nodes::LessThanOrEqual, + Arel::Nodes::Matches, + Arel::Nodes::NotEqual, + Arel::Nodes::NotIn, + Arel::Nodes::Or, + Arel::Nodes::TableAlias, + Arel::Nodes::Values, + Arel::Nodes::As, + Arel::Nodes::DeleteStatement, + Arel::Nodes::JoinSource, + Arel::Nodes::Casted, + ].each do |klass| + define_method("test_#{klass.name.gsub('::', '_')}") do + binary = klass.new(:a, :b) + @visitor.accept binary, Collectors::PlainString.new + end + end + + def test_Arel_Nodes_BindParam + node = Arel::Nodes::BindParam.new(1) + collector = Collectors::PlainString.new + assert_match '[label="<f0>Arel::Nodes::BindParam"]', @visitor.accept(node, collector).value + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/ibm_db_test.rb b/activerecord/test/cases/arel/visitors/ibm_db_test.rb new file mode 100644 index 0000000000..2ddbec3266 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/ibm_db_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class IbmDbTest < Arel::Spec + before do + @visitor = IBM_DB.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "uses FETCH FIRST n ROWS to limit results" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT FETCH FIRST 1 ROWS ONLY" + end + + it "uses FETCH FIRST n ROWS in updates with a limit" do + table = Table.new(:users) + stmt = Nodes::UpdateStatement.new + stmt.relation = table + stmt.limit = Nodes::Limit.new(Nodes.build_quoted(1)) + stmt.key = table[:id] + sql = compile(stmt) + sql.must_be_like "UPDATE \"users\" WHERE \"users\".\"id\" IN (SELECT \"users\".\"id\" FROM \"users\" FETCH FIRST 1 ROWS ONLY)" + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/informix_test.rb b/activerecord/test/cases/arel/visitors/informix_test.rb new file mode 100644 index 0000000000..b6c2dd6ae7 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/informix_test.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class InformixTest < Arel::Spec + before do + @visitor = Informix.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "uses FIRST n to limit results" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT FIRST 1" + end + + it "uses FIRST n in updates with a limit" do + table = Table.new(:users) + stmt = Nodes::UpdateStatement.new + stmt.relation = table + stmt.limit = Nodes::Limit.new(Nodes.build_quoted(1)) + stmt.key = table[:id] + sql = compile(stmt) + sql.must_be_like "UPDATE \"users\" WHERE \"users\".\"id\" IN (SELECT FIRST 1 \"users\".\"id\" FROM \"users\")" + end + + it "uses SKIP n to jump results" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT SKIP 10" + end + + it "uses SKIP before FIRST" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(1) + stmt.offset = Nodes::Offset.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT SKIP 1 FIRST 1" + end + + it "uses INNER JOIN to perform joins" do + core = Nodes::SelectCore.new + table = Table.new(:posts) + core.source = Nodes::JoinSource.new(table, [table.create_join(Table.new(:comments))]) + + stmt = Nodes::SelectStatement.new([core]) + sql = compile(stmt) + sql.must_be_like 'SELECT FROM "posts" INNER JOIN "comments"' + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + CASE WHEN "users"."name" = 'Aaron Patterson' OR ("users"."name" IS NULL AND 'Aaron Patterson' IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/mssql_test.rb b/activerecord/test/cases/arel/visitors/mssql_test.rb new file mode 100644 index 0000000000..74f34b4dad --- /dev/null +++ b/activerecord/test/cases/arel/visitors/mssql_test.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class MssqlTest < Arel::Spec + before do + @visitor = MSSQL.new Table.engine.connection + @table = Arel::Table.new "users" + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "should not modify query if no offset or limit" do + stmt = Nodes::SelectStatement.new + sql = compile(stmt) + sql.must_be_like "SELECT" + end + + it "should go over table PK if no .order() or .group()" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.from = @table + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY \"users\".\"id\") as _row_num FROM \"users\") as _t WHERE _row_num BETWEEN 1 AND 10" + end + + it "caches the PK lookup for order" do + connection = Minitest::Mock.new + connection.expect(:primary_key, ["id"], ["users"]) + + # We don't care how many times these methods are called + def connection.quote_table_name(*); ""; end + def connection.quote_column_name(*); ""; end + + @visitor = MSSQL.new(connection) + stmt = Nodes::SelectStatement.new + stmt.cores.first.from = @table + stmt.limit = Nodes::Limit.new(10) + + compile(stmt) + compile(stmt) + + connection.verify + end + + it "should use TOP for limited deletes" do + stmt = Nodes::DeleteStatement.new + stmt.relation = @table + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + + sql.must_be_like "DELETE TOP (10) FROM \"users\"" + end + + it "should go over query ORDER BY if .order()" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.orders << Nodes::SqlLiteral.new("order_by") + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY order_by) as _row_num) as _t WHERE _row_num BETWEEN 1 AND 10" + end + + it "should go over query GROUP BY if no .order() and there is .group()" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.groups << Nodes::SqlLiteral.new("group_by") + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY group_by) as _row_num GROUP BY group_by) as _t WHERE _row_num BETWEEN 1 AND 10" + end + + it "should use BETWEEN if both .limit() and .offset" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.offset = Nodes::Offset.new(20) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num BETWEEN 21 AND 30" + end + + it "should use >= if only .offset" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(20) + sql = compile(stmt) + sql.must_be_like "SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num >= 21" + end + + it "should generate subquery for .count" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.cores.first.projections << Nodes::Count.new("*") + sql = compile(stmt) + sql.must_be_like "SELECT COUNT(1) as count_id FROM (SELECT _t.* FROM (SELECT ROW_NUMBER() OVER (ORDER BY ) as _row_num) as _t WHERE _row_num BETWEEN 1 AND 10) AS subquery" + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + EXISTS (VALUES ("users"."name") INTERSECT VALUES ('Aaron Patterson')) + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + EXISTS (VALUES ("users"."first_name") INTERSECT VALUES ("users"."last_name")) + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + NOT EXISTS (VALUES ("users"."first_name") INTERSECT VALUES ("users"."last_name")) + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/mysql_test.rb b/activerecord/test/cases/arel/visitors/mysql_test.rb new file mode 100644 index 0000000000..5f37587957 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/mysql_test.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class MysqlTest < Arel::Spec + before do + @visitor = MySQL.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + ### + # :'( + # http://dev.mysql.com/doc/refman/5.0/en/select.html#id3482214 + it "defaults limit to 18446744073709551615" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(1) + sql = compile(stmt) + sql.must_be_like "SELECT FROM DUAL LIMIT 18446744073709551615 OFFSET 1" + end + + it "should escape LIMIT" do + sc = Arel::Nodes::UpdateStatement.new + sc.relation = Table.new(:users) + sc.limit = Nodes::Limit.new(Nodes.build_quoted("omg")) + assert_equal("UPDATE \"users\" LIMIT 'omg'", compile(sc)) + end + + it "uses DUAL for empty from" do + stmt = Nodes::SelectStatement.new + sql = compile(stmt) + sql.must_be_like "SELECT FROM DUAL" + end + + describe "locking" do + it "defaults to FOR UPDATE when locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + compile(node).must_be_like "FOR UPDATE" + end + + it "allows a custom string to be used as a lock" do + node = Nodes::Lock.new(Arel.sql("LOCK IN SHARE MODE")) + compile(node).must_be_like "LOCK IN SHARE MODE" + end + end + + describe "concat" do + it "concats columns" do + @table = Table.new(:users) + query = @table[:name].concat(@table[:name]) + compile(query).must_be_like %{ + CONCAT("users"."name", "users"."name") + } + end + + it "concats a string" do + @table = Table.new(:users) + query = @table[:name].concat(Nodes.build_quoted("abc")) + compile(query).must_be_like %{ + CONCAT("users"."name", 'abc') + } + end + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" <=> 'Aaron Patterson' + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" <=> "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" <=> NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + NOT "users"."first_name" <=> "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ NOT "users"."name" <=> NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/oracle12_test.rb b/activerecord/test/cases/arel/visitors/oracle12_test.rb new file mode 100644 index 0000000000..4ce5cab4db --- /dev/null +++ b/activerecord/test/cases/arel/visitors/oracle12_test.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class Oracle12Test < Arel::Spec + before do + @visitor = Oracle12.new Table.engine.connection + @table = Table.new(:users) + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "modified except to be minus" do + left = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 10") + right = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 20") + sql = compile Nodes::Except.new(left, right) + sql.must_be_like %{ + ( SELECT * FROM users WHERE age > 10 MINUS SELECT * FROM users WHERE age > 20 ) + } + end + + it "generates select options offset then limit" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(1) + stmt.limit = Nodes::Limit.new(10) + sql = compile(stmt) + sql.must_be_like "SELECT OFFSET 1 ROWS FETCH FIRST 10 ROWS ONLY" + end + + describe "locking" do + it "generates ArgumentError if limit and lock are used" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.lock = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + assert_raises ArgumentError do + compile(stmt) + end + end + + it "defaults to FOR UPDATE when locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + compile(node).must_be_like "FOR UPDATE" + end + end + + describe "Nodes::BindParam" do + it "increments each bind param" do + query = @table[:name].eq(Arel::Nodes::BindParam.new(1)) + .and(@table[:id].eq(Arel::Nodes::BindParam.new(1))) + compile(query).must_be_like %{ + "users"."name" = :a1 AND "users"."id" = :a2 + } + end + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/oracle_test.rb b/activerecord/test/cases/arel/visitors/oracle_test.rb new file mode 100644 index 0000000000..893edc7f74 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/oracle_test.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class OracleTest < Arel::Spec + before do + @visitor = Oracle.new Table.engine.connection + @table = Table.new(:users) + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "modifies order when there is distinct and first value" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("foo") + sql = compile(stmt) + sql.must_be_like %{ + SELECT #{select} ORDER BY alias_0__ + } + end + + it "is idempotent with crazy query" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("foo") + + sql = compile(stmt) + sql2 = compile(stmt) + sql.must_equal sql2 + end + + it "splits orders with commas" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("foo, bar") + sql = compile(stmt) + sql.must_be_like %{ + SELECT #{select} ORDER BY alias_0__, alias_1__ + } + end + + it "splits orders with commas and function calls" do + # *sigh* + select = "DISTINCT foo.id, FIRST_VALUE(projects.name) OVER (foo) AS alias_0__" + stmt = Nodes::SelectStatement.new + stmt.cores.first.projections << Nodes::SqlLiteral.new(select) + stmt.orders << Nodes::SqlLiteral.new("NVL(LOWER(bar, foo), foo) DESC, UPPER(baz)") + sql = compile(stmt) + sql.must_be_like %{ + SELECT #{select} ORDER BY alias_0__ DESC, alias_1__ + } + end + + describe "Nodes::SelectStatement" do + describe "limit" do + it "adds a rownum clause" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ SELECT WHERE ROWNUM <= 10 } + end + + it "is idempotent" do + stmt = Nodes::SelectStatement.new + stmt.orders << Nodes::SqlLiteral.new("foo") + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql2 = compile stmt + sql.must_equal sql2 + end + + it "creates a subquery when there is order_by" do + stmt = Nodes::SelectStatement.new + stmt.orders << Nodes::SqlLiteral.new("foo") + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM (SELECT ORDER BY foo ) WHERE ROWNUM <= 10 + } + end + + it "creates a subquery when there is group by" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.groups << Nodes::SqlLiteral.new("foo") + stmt.limit = Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM (SELECT GROUP BY foo ) WHERE ROWNUM <= 10 + } + end + + it "creates a subquery when there is DISTINCT" do + stmt = Nodes::SelectStatement.new + stmt.cores.first.set_quantifier = Arel::Nodes::Distinct.new + stmt.cores.first.projections << Nodes::SqlLiteral.new("id") + stmt.limit = Arel::Nodes::Limit.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM (SELECT DISTINCT id ) WHERE ROWNUM <= 10 + } + end + + it "creates a different subquery when there is an offset" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.offset = Nodes::Offset.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (SELECT ) raw_sql_ + WHERE rownum <= 20 + ) + WHERE raw_rnum_ > 10 + } + end + + it "creates a subquery when there is limit and offset with BindParams" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(Nodes::BindParam.new(1)) + stmt.offset = Nodes::Offset.new(Nodes::BindParam.new(1)) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (SELECT ) raw_sql_ + WHERE rownum <= (:a1 + :a2) + ) + WHERE raw_rnum_ > :a3 + } + end + + it "is idempotent with different subquery" do + stmt = Nodes::SelectStatement.new + stmt.limit = Nodes::Limit.new(10) + stmt.offset = Nodes::Offset.new(10) + sql = compile stmt + sql2 = compile stmt + sql.must_equal sql2 + end + end + + describe "only offset" do + it "creates a select from subquery with rownum condition" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(10) + sql = compile stmt + sql.must_be_like %{ + SELECT * FROM ( + SELECT raw_sql_.*, rownum raw_rnum_ + FROM (SELECT) raw_sql_ + ) + WHERE raw_rnum_ > 10 + } + end + end + end + + it "modified except to be minus" do + left = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 10") + right = Nodes::SqlLiteral.new("SELECT * FROM users WHERE age > 20") + sql = compile Nodes::Except.new(left, right) + sql.must_be_like %{ + ( SELECT * FROM users WHERE age > 10 MINUS SELECT * FROM users WHERE age > 20 ) + } + end + + describe "locking" do + it "defaults to FOR UPDATE when locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + compile(node).must_be_like "FOR UPDATE" + end + end + + describe "Nodes::BindParam" do + it "increments each bind param" do + query = @table[:name].eq(Arel::Nodes::BindParam.new(1)) + .and(@table[:id].eq(Arel::Nodes::BindParam.new(1))) + compile(query).must_be_like %{ + "users"."name" = :a1 AND "users"."id" = :a2 + } + end + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + DECODE("users"."name", 'Aaron Patterson', 0, 1) = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 0 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + DECODE("users"."first_name", "users"."last_name", 0, 1) = 1 + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/postgres_test.rb b/activerecord/test/cases/arel/visitors/postgres_test.rb new file mode 100644 index 0000000000..0f9efb20b4 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/postgres_test.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class PostgresTest < Arel::Spec + before do + @visitor = PostgreSQL.new Table.engine.connection + @table = Table.new(:users) + @attr = @table[:id] + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + describe "locking" do + it "defaults to FOR UPDATE" do + compile(Nodes::Lock.new(Arel.sql("FOR UPDATE"))).must_be_like %{ + FOR UPDATE + } + end + + it "allows a custom string to be used as a lock" do + node = Nodes::Lock.new(Arel.sql("FOR SHARE")) + compile(node).must_be_like %{ + FOR SHARE + } + end + end + + it "should escape LIMIT" do + sc = Arel::Nodes::SelectStatement.new + sc.limit = Nodes::Limit.new(Nodes.build_quoted("omg")) + sc.cores.first.projections << Arel.sql("DISTINCT ON") + sc.orders << Arel.sql("xyz") + sql = compile(sc) + assert_match(/LIMIT 'omg'/, sql) + assert_equal 1, sql.scan(/LIMIT/).length, "should have one limit" + end + + it "should support DISTINCT ON" do + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::DistinctOn.new(Arel.sql("aaron")) + assert_match "DISTINCT ON ( aaron )", compile(core) + end + + it "should support DISTINCT" do + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::Distinct.new + assert_equal "SELECT DISTINCT", compile(core) + end + + it "encloses LATERAL queries in parens" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + compile(subquery.lateral).must_be_like %{ + LATERAL (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') + } + end + + it "produces LATERAL queries with alias" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + compile(subquery.lateral("bar")).must_be_like %{ + LATERAL (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') bar + } + end + + describe "Nodes::Matches" do + it "should know how to visit" do + node = @table[:name].matches("foo%") + node.must_be_kind_of Nodes::Matches + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" ILIKE 'foo%' + } + end + + it "should know how to visit case sensitive" do + node = @table[:name].matches("foo%", nil, true) + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].matches("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" ILIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" ILIKE 'foo%') + } + end + end + + describe "Nodes::DoesNotMatch" do + it "should know how to visit" do + node = @table[:name].does_not_match("foo%") + node.must_be_kind_of Nodes::DoesNotMatch + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" NOT ILIKE 'foo%' + } + end + + it "should know how to visit case sensitive" do + node = @table[:name].does_not_match("foo%", nil, true) + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" NOT LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].does_not_match("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" NOT ILIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].does_not_match("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" NOT ILIKE 'foo%') + } + end + end + + describe "Nodes::Regexp" do + it "should know how to visit" do + node = @table[:name].matches_regexp("foo.*") + node.must_be_kind_of Nodes::Regexp + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" ~ 'foo.*' + } + end + + it "can handle case insensitive" do + node = @table[:name].matches_regexp("foo.*", false) + node.must_be_kind_of Nodes::Regexp + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" ~* 'foo.*' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].matches_regexp("foo.*")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" ~ 'foo.*') + } + end + end + + describe "Nodes::NotRegexp" do + it "should know how to visit" do + node = @table[:name].does_not_match_regexp("foo.*") + node.must_be_kind_of Nodes::NotRegexp + node.case_sensitive.must_equal(true) + compile(node).must_be_like %{ + "users"."name" !~ 'foo.*' + } + end + + it "can handle case insensitive" do + node = @table[:name].does_not_match_regexp("foo.*", false) + node.case_sensitive.must_equal(false) + compile(node).must_be_like %{ + "users"."name" !~* 'foo.*' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].does_not_match_regexp("foo.*")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" !~ 'foo.*') + } + end + end + + describe "Nodes::BindParam" do + it "increments each bind param" do + query = @table[:name].eq(Arel::Nodes::BindParam.new(1)) + .and(@table[:id].eq(Arel::Nodes::BindParam.new(1))) + compile(query).must_be_like %{ + "users"."name" = $1 AND "users"."id" = $2 + } + end + end + + describe "Nodes::Cube" do + it "should know how to visit with array arguments" do + node = Arel::Nodes::Cube.new([@table[:name], @table[:bool]]) + compile(node).must_be_like %{ + CUBE( "users"."name", "users"."bool" ) + } + end + + it "should know how to visit with CubeDimension Argument" do + dimensions = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]]) + node = Arel::Nodes::Cube.new(dimensions) + compile(node).must_be_like %{ + CUBE( "users"."name", "users"."bool" ) + } + end + + it "should know how to generate parenthesis when supplied with many Dimensions" do + dim1 = Arel::Nodes::GroupingElement.new(@table[:name]) + dim2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]]) + node = Arel::Nodes::Cube.new([dim1, dim2]) + compile(node).must_be_like %{ + CUBE( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) ) + } + end + end + + describe "Nodes::GroupingSet" do + it "should know how to visit with array arguments" do + node = Arel::Nodes::GroupingSet.new([@table[:name], @table[:bool]]) + compile(node).must_be_like %{ + GROUPING SETS( "users"."name", "users"."bool" ) + } + end + + it "should know how to visit with CubeDimension Argument" do + group = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]]) + node = Arel::Nodes::GroupingSet.new(group) + compile(node).must_be_like %{ + GROUPING SETS( "users"."name", "users"."bool" ) + } + end + + it "should know how to generate parenthesis when supplied with many Dimensions" do + group1 = Arel::Nodes::GroupingElement.new(@table[:name]) + group2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]]) + node = Arel::Nodes::GroupingSet.new([group1, group2]) + compile(node).must_be_like %{ + GROUPING SETS( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) ) + } + end + end + + describe "Nodes::RollUp" do + it "should know how to visit with array arguments" do + node = Arel::Nodes::RollUp.new([@table[:name], @table[:bool]]) + compile(node).must_be_like %{ + ROLLUP( "users"."name", "users"."bool" ) + } + end + + it "should know how to visit with CubeDimension Argument" do + group = Arel::Nodes::GroupingElement.new([@table[:name], @table[:bool]]) + node = Arel::Nodes::RollUp.new(group) + compile(node).must_be_like %{ + ROLLUP( "users"."name", "users"."bool" ) + } + end + + it "should know how to generate parenthesis when supplied with many Dimensions" do + group1 = Arel::Nodes::GroupingElement.new(@table[:name]) + group2 = Arel::Nodes::GroupingElement.new([@table[:bool], @table[:created_at]]) + node = Arel::Nodes::RollUp.new([group1, group2]) + compile(node).must_be_like %{ + ROLLUP( ( "users"."name" ), ( "users"."bool", "users"."created_at" ) ) + } + end + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" IS NOT DISTINCT FROM 'Aaron Patterson' + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS NOT DISTINCT FROM "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT DISTINCT FROM NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS DISTINCT FROM "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS DISTINCT FROM NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/sqlite_test.rb b/activerecord/test/cases/arel/visitors/sqlite_test.rb new file mode 100644 index 0000000000..ee4e07a675 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/sqlite_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require_relative "../helper" + +module Arel + module Visitors + class SqliteTest < Arel::Spec + before do + @visitor = SQLite.new Table.engine.connection + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "defaults limit to -1" do + stmt = Nodes::SelectStatement.new + stmt.offset = Nodes::Offset.new(1) + sql = @visitor.accept(stmt, Collectors::SQLString.new).value + sql.must_be_like "SELECT LIMIT -1 OFFSET 1" + end + + it "does not support locking" do + node = Nodes::Lock.new(Arel.sql("FOR UPDATE")) + assert_equal "", @visitor.accept(node, Collectors::SQLString.new).value + end + + it "does not support boolean" do + node = Nodes::True.new() + assert_equal "1", @visitor.accept(node, Collectors::SQLString.new).value + node = Nodes::False.new() + assert_equal "0", @visitor.accept(node, Collectors::SQLString.new).value + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" IS 'Aaron Patterson' + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + "users"."first_name" IS NOT "users"."last_name" + } + end + + it "should handle nil" do + @table = Table.new(:users) + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + end + end +end diff --git a/activerecord/test/cases/arel/visitors/to_sql_test.rb b/activerecord/test/cases/arel/visitors/to_sql_test.rb new file mode 100644 index 0000000000..4bfa799a96 --- /dev/null +++ b/activerecord/test/cases/arel/visitors/to_sql_test.rb @@ -0,0 +1,713 @@ +# frozen_string_literal: true + +require_relative "../helper" +require "bigdecimal" + +module Arel + module Visitors + describe "the to_sql visitor" do + before do + @conn = FakeRecord::Base.new + @visitor = ToSql.new @conn.connection + @table = Table.new(:users) + @attr = @table[:id] + end + + def compile(node) + @visitor.accept(node, Collectors::SQLString.new).value + end + + it "works with BindParams" do + node = Nodes::BindParam.new(1) + sql = compile node + sql.must_be_like "?" + end + + it "does not quote BindParams used as part of a Values" do + bp = Nodes::BindParam.new(1) + values = Nodes::Values.new([bp]) + sql = compile values + sql.must_be_like "VALUES (?)" + end + + it "can define a dispatch method" do + visited = false + viz = Class.new(Arel::Visitors::Visitor) { + define_method(:hello) do |node, c| + visited = true + end + + def dispatch + { Arel::Table => "hello" } + end + }.new + + viz.accept(@table, Collectors::SQLString.new) + assert visited, "hello method was called" + end + + it "should not quote sql literals" do + node = @table[Arel.star] + sql = compile node + sql.must_be_like '"users".*' + end + + it "should visit named functions" do + function = Nodes::NamedFunction.new("omg", [Arel.star]) + assert_equal "omg(*)", compile(function) + end + + it "should chain predications on named functions" do + function = Nodes::NamedFunction.new("omg", [Arel.star]) + sql = compile(function.eq(2)) + sql.must_be_like %{ omg(*) = 2 } + end + + it "should handle nil with named functions" do + function = Nodes::NamedFunction.new("omg", [Arel.star]) + sql = compile(function.eq(nil)) + sql.must_be_like %{ omg(*) IS NULL } + end + + it "should visit built-in functions" do + function = Nodes::Count.new([Arel.star]) + assert_equal "COUNT(*)", compile(function) + + function = Nodes::Sum.new([Arel.star]) + assert_equal "SUM(*)", compile(function) + + function = Nodes::Max.new([Arel.star]) + assert_equal "MAX(*)", compile(function) + + function = Nodes::Min.new([Arel.star]) + assert_equal "MIN(*)", compile(function) + + function = Nodes::Avg.new([Arel.star]) + assert_equal "AVG(*)", compile(function) + end + + it "should visit built-in functions operating on distinct values" do + function = Nodes::Count.new([Arel.star]) + function.distinct = true + assert_equal "COUNT(DISTINCT *)", compile(function) + + function = Nodes::Sum.new([Arel.star]) + function.distinct = true + assert_equal "SUM(DISTINCT *)", compile(function) + + function = Nodes::Max.new([Arel.star]) + function.distinct = true + assert_equal "MAX(DISTINCT *)", compile(function) + + function = Nodes::Min.new([Arel.star]) + function.distinct = true + assert_equal "MIN(DISTINCT *)", compile(function) + + function = Nodes::Avg.new([Arel.star]) + function.distinct = true + assert_equal "AVG(DISTINCT *)", compile(function) + end + + it "works with lists" do + function = Nodes::NamedFunction.new("omg", [Arel.star, Arel.star]) + assert_equal "omg(*, *)", compile(function) + end + + describe "Nodes::Equality" do + it "should escape strings" do + test = Table.new(:users)[:name].eq "Aaron Patterson" + compile(test).must_be_like %{ + "users"."name" = 'Aaron Patterson' + } + end + + it "should handle false" do + table = Table.new(:users) + val = Nodes.build_quoted(false, table[:active]) + sql = compile Nodes::Equality.new(val, val) + sql.must_be_like %{ 'f' = 'f' } + end + + it "should handle nil" do + sql = compile Nodes::Equality.new(@table[:name], nil) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::Grouping" do + it "wraps nested groupings in brackets only once" do + sql = compile Nodes::Grouping.new(Nodes::Grouping.new(Nodes.build_quoted("foo"))) + sql.must_equal "('foo')" + end + end + + describe "Nodes::NotEqual" do + it "should handle false" do + val = Nodes.build_quoted(false, @table[:active]) + sql = compile Nodes::NotEqual.new(@table[:active], val) + sql.must_be_like %{ "users"."active" != 'f' } + end + + it "should handle nil" do + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::NotEqual.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + + describe "Nodes::IsNotDistinctFrom" do + it "should construct a valid generic SQL statement" do + test = Table.new(:users)[:name].is_not_distinct_from "Aaron Patterson" + compile(test).must_be_like %{ + CASE WHEN "users"."name" = 'Aaron Patterson' OR ("users"."name" IS NULL AND 'Aaron Patterson' IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_not_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 0 + } + end + + it "should handle nil" do + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsNotDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NULL } + end + end + + describe "Nodes::IsDistinctFrom" do + it "should handle column names on both sides" do + test = Table.new(:users)[:first_name].is_distinct_from Table.new(:users)[:last_name] + compile(test).must_be_like %{ + CASE WHEN "users"."first_name" = "users"."last_name" OR ("users"."first_name" IS NULL AND "users"."last_name" IS NULL) THEN 0 ELSE 1 END = 1 + } + end + + it "should handle nil" do + val = Nodes.build_quoted(nil, @table[:active]) + sql = compile Nodes::IsDistinctFrom.new(@table[:name], val) + sql.must_be_like %{ "users"."name" IS NOT NULL } + end + end + + it "should visit string subclass" do + [ + Class.new(String).new(":'("), + Class.new(Class.new(String)).new(":'("), + ].each do |obj| + val = Nodes.build_quoted(obj, @table[:active]) + sql = compile Nodes::NotEqual.new(@table[:name], val) + sql.must_be_like %{ "users"."name" != ':\\'(' } + end + end + + it "should visit_Class" do + compile(Nodes.build_quoted(DateTime)).must_equal "'DateTime'" + end + + it "should escape LIMIT" do + sc = Arel::Nodes::SelectStatement.new + sc.limit = Arel::Nodes::Limit.new(Nodes.build_quoted("omg")) + assert_match(/LIMIT 'omg'/, compile(sc)) + end + + it "should contain a single space before ORDER BY" do + table = Table.new(:users) + test = table.order(table[:name]) + sql = compile test + assert_match(/"users" ORDER BY/, sql) + end + + it "should quote LIMIT without column type coercion" do + table = Table.new(:users) + sc = table.where(table[:name].eq(0)).take(1).ast + assert_match(/WHERE "users"."name" = 0 LIMIT 1/, compile(sc)) + end + + it "should visit_DateTime" do + dt = DateTime.now + table = Table.new(:users) + test = table[:created_at].eq dt + sql = compile test + + sql.must_be_like %{"users"."created_at" = '#{dt.strftime("%Y-%m-%d %H:%M:%S")}'} + end + + it "should visit_Float" do + test = Table.new(:products)[:price].eq 2.14 + sql = compile test + sql.must_be_like %{"products"."price" = 2.14} + end + + it "should visit_Not" do + sql = compile Nodes::Not.new(Arel.sql("foo")) + sql.must_be_like "NOT (foo)" + end + + it "should apply Not to the whole expression" do + node = Nodes::And.new [@attr.eq(10), @attr.eq(11)] + sql = compile Nodes::Not.new(node) + sql.must_be_like %{NOT ("users"."id" = 10 AND "users"."id" = 11)} + end + + it "should visit_As" do + as = Nodes::As.new(Arel.sql("foo"), Arel.sql("bar")) + sql = compile as + sql.must_be_like "foo AS bar" + end + + it "should visit_Integer" do + compile 8787878092 + end + + it "should visit_Hash" do + compile(Nodes.build_quoted(a: 1)) + end + + it "should visit_Set" do + compile Nodes.build_quoted(Set.new([1, 2])) + end + + it "should visit_BigDecimal" do + compile Nodes.build_quoted(BigDecimal("2.14")) + end + + it "should visit_Date" do + dt = Date.today + table = Table.new(:users) + test = table[:created_at].eq dt + sql = compile test + + sql.must_be_like %{"users"."created_at" = '#{dt.strftime("%Y-%m-%d")}'} + end + + it "should visit_NilClass" do + compile(Nodes.build_quoted(nil)).must_be_like "NULL" + end + + it "unsupported input should raise UnsupportedVisitError" do + error = assert_raises(UnsupportedVisitError) { compile(nil) } + assert_match(/\AUnsupported/, error.message) + end + + it "should visit_Arel_SelectManager, which is a subquery" do + mgr = Table.new(:foo).project(:bar) + compile(mgr).must_be_like '(SELECT bar FROM "foo")' + end + + it "should visit_Arel_Nodes_And" do + node = Nodes::And.new [@attr.eq(10), @attr.eq(11)] + compile(node).must_be_like %{ + "users"."id" = 10 AND "users"."id" = 11 + } + end + + it "should visit_Arel_Nodes_Or" do + node = Nodes::Or.new @attr.eq(10), @attr.eq(11) + compile(node).must_be_like %{ + "users"."id" = 10 OR "users"."id" = 11 + } + end + + it "should visit_Arel_Nodes_Assignment" do + column = @table["id"] + node = Nodes::Assignment.new( + Nodes::UnqualifiedColumn.new(column), + Nodes::UnqualifiedColumn.new(column) + ) + compile(node).must_be_like %{ + "id" = "id" + } + end + + it "should visit visit_Arel_Attributes_Time" do + attr = Attributes::Time.new(@attr.relation, @attr.name) + compile attr + end + + it "should visit_TrueClass" do + test = Table.new(:users)[:bool].eq(true) + compile(test).must_be_like %{ "users"."bool" = 't' } + end + + describe "Nodes::Matches" do + it "should know how to visit" do + node = @table[:name].matches("foo%") + compile(node).must_be_like %{ + "users"."name" LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].matches("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" LIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].matches("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" LIKE 'foo%') + } + end + end + + describe "Nodes::DoesNotMatch" do + it "should know how to visit" do + node = @table[:name].does_not_match("foo%") + compile(node).must_be_like %{ + "users"."name" NOT LIKE 'foo%' + } + end + + it "can handle ESCAPE" do + node = @table[:name].does_not_match("foo!%", "!") + compile(node).must_be_like %{ + "users"."name" NOT LIKE 'foo!%' ESCAPE '!' + } + end + + it "can handle subqueries" do + subquery = @table.project(:id).where(@table[:name].does_not_match("foo%")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" NOT LIKE 'foo%') + } + end + end + + describe "Nodes::Ordering" do + it "should know how to visit" do + node = @attr.desc + compile(node).must_be_like %{ + "users"."id" DESC + } + end + end + + describe "Nodes::In" do + it "should know how to visit" do + node = @attr.in [1, 2, 3] + compile(node).must_be_like %{ + "users"."id" IN (1, 2, 3) + } + end + + it "should return 1=0 when empty right which is always false" do + node = @attr.in [] + compile(node).must_equal "1=0" + end + + it "can handle two dot ranges" do + node = @attr.between 1..3 + compile(node).must_be_like %{ + "users"."id" BETWEEN 1 AND 3 + } + end + + it "can handle three dot ranges" do + node = @attr.between 1...3 + compile(node).must_be_like %{ + "users"."id" >= 1 AND "users"."id" < 3 + } + end + + it "can handle ranges bounded by infinity" do + node = @attr.between 1..Float::INFINITY + compile(node).must_be_like %{ + "users"."id" >= 1 + } + node = @attr.between(-Float::INFINITY..3) + compile(node).must_be_like %{ + "users"."id" <= 3 + } + node = @attr.between(-Float::INFINITY...3) + compile(node).must_be_like %{ + "users"."id" < 3 + } + node = @attr.between(-Float::INFINITY..Float::INFINITY) + compile(node).must_be_like %{1=1} + end + + it "can handle subqueries" do + table = Table.new(:users) + subquery = table.project(:id).where(table[:name].eq("Aaron")) + node = @attr.in subquery + compile(node).must_be_like %{ + "users"."id" IN (SELECT id FROM "users" WHERE "users"."name" = 'Aaron') + } + end + end + + describe "Nodes::InfixOperation" do + it "should handle Multiplication" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) * Arel::Attributes::Decimal.new(Table.new(:currency_rates), :rate) + compile(node).must_equal %("products"."price" * "currency_rates"."rate") + end + + it "should handle Division" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) / 5 + compile(node).must_equal %("products"."price" / 5) + end + + it "should handle Addition" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) + 6 + compile(node).must_equal %(("products"."price" + 6)) + end + + it "should handle Subtraction" do + node = Arel::Attributes::Decimal.new(Table.new(:products), :price) - 7 + compile(node).must_equal %(("products"."price" - 7)) + end + + it "should handle Concatenation" do + table = Table.new(:users) + node = table[:name].concat(table[:name]) + compile(node).must_equal %("users"."name" || "users"."name") + end + + it "should handle BitwiseAnd" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) & 16 + compile(node).must_equal %(("products"."bitmap" & 16)) + end + + it "should handle BitwiseOr" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) | 16 + compile(node).must_equal %(("products"."bitmap" | 16)) + end + + it "should handle BitwiseXor" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) ^ 16 + compile(node).must_equal %(("products"."bitmap" ^ 16)) + end + + it "should handle BitwiseShiftLeft" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) << 4 + compile(node).must_equal %(("products"."bitmap" << 4)) + end + + it "should handle BitwiseShiftRight" do + node = Arel::Attributes::Integer.new(Table.new(:products), :bitmap) >> 4 + compile(node).must_equal %(("products"."bitmap" >> 4)) + end + + it "should handle arbitrary operators" do + node = Arel::Nodes::InfixOperation.new( + "&&", + Arel::Attributes::String.new(Table.new(:products), :name), + Arel::Attributes::String.new(Table.new(:products), :name) + ) + compile(node).must_equal %("products"."name" && "products"."name") + end + end + + describe "Nodes::UnaryOperation" do + it "should handle BitwiseNot" do + node = ~ Arel::Attributes::Integer.new(Table.new(:products), :bitmap) + compile(node).must_equal %( ~ "products"."bitmap") + end + + it "should handle arbitrary operators" do + node = Arel::Nodes::UnaryOperation.new("!", Arel::Attributes::String.new(Table.new(:products), :active)) + compile(node).must_equal %( ! "products"."active") + end + end + + describe "Nodes::Union" do + it "squashes parenthesis on multiple unions" do + subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right") + node = Nodes::Union.new subnode, Arel.sql("topright") + assert_equal("( left UNION right UNION topright )", compile(node)) + subnode = Nodes::Union.new Arel.sql("left"), Arel.sql("right") + node = Nodes::Union.new Arel.sql("topleft"), subnode + assert_equal("( topleft UNION left UNION right )", compile(node)) + end + end + + describe "Nodes::UnionAll" do + it "squashes parenthesis on multiple union alls" do + subnode = Nodes::UnionAll.new Arel.sql("left"), Arel.sql("right") + node = Nodes::UnionAll.new subnode, Arel.sql("topright") + assert_equal("( left UNION ALL right UNION ALL topright )", compile(node)) + subnode = Nodes::UnionAll.new Arel.sql("left"), Arel.sql("right") + node = Nodes::UnionAll.new Arel.sql("topleft"), subnode + assert_equal("( topleft UNION ALL left UNION ALL right )", compile(node)) + end + end + + describe "Nodes::NotIn" do + it "should know how to visit" do + node = @attr.not_in [1, 2, 3] + compile(node).must_be_like %{ + "users"."id" NOT IN (1, 2, 3) + } + end + + it "should return 1=1 when empty right which is always true" do + node = @attr.not_in [] + compile(node).must_equal "1=1" + end + + it "can handle two dot ranges" do + node = @attr.not_between 1..3 + compile(node).must_equal( + %{("users"."id" < 1 OR "users"."id" > 3)} + ) + end + + it "can handle three dot ranges" do + node = @attr.not_between 1...3 + compile(node).must_equal( + %{("users"."id" < 1 OR "users"."id" >= 3)} + ) + end + + it "can handle ranges bounded by infinity" do + node = @attr.not_between 1..Float::INFINITY + compile(node).must_be_like %{ + "users"."id" < 1 + } + node = @attr.not_between(-Float::INFINITY..3) + compile(node).must_be_like %{ + "users"."id" > 3 + } + node = @attr.not_between(-Float::INFINITY...3) + compile(node).must_be_like %{ + "users"."id" >= 3 + } + node = @attr.not_between(-Float::INFINITY..Float::INFINITY) + compile(node).must_be_like %{1=0} + end + + it "can handle subqueries" do + table = Table.new(:users) + subquery = table.project(:id).where(table[:name].eq("Aaron")) + node = @attr.not_in subquery + compile(node).must_be_like %{ + "users"."id" NOT IN (SELECT id FROM "users" WHERE "users"."name" = 'Aaron') + } + end + end + + describe "Constants" do + it "should handle true" do + test = Table.new(:users).create_true + compile(test).must_be_like %{ + TRUE + } + end + + it "should handle false" do + test = Table.new(:users).create_false + compile(test).must_be_like %{ + FALSE + } + end + end + + describe "TableAlias" do + it "should use the underlying table for checking columns" do + test = Table.new(:users).alias("zomgusers")[:id].eq "3" + compile(test).must_be_like %{ + "zomgusers"."id" = '3' + } + end + end + + describe "distinct on" do + it "raises not implemented error" do + core = Arel::Nodes::SelectCore.new + core.set_quantifier = Arel::Nodes::DistinctOn.new(Arel.sql("aaron")) + + assert_raises(NotImplementedError) do + compile(core) + end + end + end + + describe "Nodes::Regexp" do + it "raises not implemented error" do + node = Arel::Nodes::Regexp.new(@table[:name], Nodes.build_quoted("foo%")) + + assert_raises(NotImplementedError) do + compile(node) + end + end + end + + describe "Nodes::NotRegexp" do + it "raises not implemented error" do + node = Arel::Nodes::NotRegexp.new(@table[:name], Nodes.build_quoted("foo%")) + + assert_raises(NotImplementedError) do + compile(node) + end + end + end + + describe "Nodes::Case" do + it "supports simple case expressions" do + node = Arel::Nodes::Case.new(@table[:name]) + .when("foo").then(1) + .else(0) + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 ELSE 0 END + } + end + + it "supports extended case expressions" do + node = Arel::Nodes::Case.new + .when(@table[:name].in(%w(foo bar))).then(1) + .else(0) + + compile(node).must_be_like %{ + CASE WHEN "users"."name" IN ('foo', 'bar') THEN 1 ELSE 0 END + } + end + + it "works without default branch" do + node = Arel::Nodes::Case.new(@table[:name]) + .when("foo").then(1) + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 END + } + end + + it "allows chaining multiple conditions" do + node = Arel::Nodes::Case.new(@table[:name]) + .when("foo").then(1) + .when("bar").then(2) + .else(0) + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 WHEN 'bar' THEN 2 ELSE 0 END + } + end + + it "supports #when with two arguments and no #then" do + node = Arel::Nodes::Case.new @table[:name] + + { foo: 1, bar: 0 }.reduce(node) { |_node, pair| _node.when(*pair) } + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 1 WHEN 'bar' THEN 0 END + } + end + + it "can be chained as a predicate" do + node = @table[:name].when("foo").then("bar").else("baz") + + compile(node).must_be_like %{ + CASE "users"."name" WHEN 'foo' THEN 'bar' ELSE 'baz' END + } + end + 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..acafbe0b4d --- /dev/null +++ b/activerecord/test/cases/associations/belongs_to_associations_test.rb @@ -0,0 +1,1376 @@ +# frozen_string_literal: true + +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" +require "models/admin" +require "models/admin/user" +require "models/ship" +require "models/treasure" +require "models/parrot" + +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 + client = Client.find(3) + first_firm = companies(:first_firm) + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + assert_equal first_firm, client.firm + assert_equal first_firm.name, client.firm.name + end + end + + def test_assigning_belongs_to_on_destroyed_object + client = Client.create!(name: "Client") + client.destroy! + assert_raise(FrozenError) { client.firm = nil } + assert_raise(FrozenError) { client.firm = Firm.new(name: "Firm") } + end + + def test_eager_loading_wont_mutate_owner_record + client = Client.eager_load(:firm_with_basic_id).first + assert_not_predicate client, :firm_id_came_from_user? + + client = Client.preload(:firm_with_basic_id).first + assert_not_predicate client, :firm_id_came_from_user? + end + + def test_missing_attribute_error_is_raised_when_no_foreign_key_attribute + assert_raises(ActiveModel::MissingAttributeError) { Client.select(:id).first.firm } + 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?(: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_optional_relation + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = true + + model = Class.new(ActiveRecord::Base) do + self.table_name = "accounts" + def self.name; "Temp"; end + belongs_to :company, optional: true + end + + account = model.new + assert_predicate account, :valid? + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + end + + def test_not_optional_relation + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = true + + model = Class.new(ActiveRecord::Base) do + self.table_name = "accounts" + def self.name; "Temp"; end + belongs_to :company, optional: false + end + + account = model.new + assert_not_predicate account, :valid? + assert_equal [{ error: :blank }], account.errors.details[:company] + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + end + + def test_required_belongs_to_config + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = true + + model = Class.new(ActiveRecord::Base) do + self.table_name = "accounts" + def self.name; "Temp"; end + belongs_to :company + end + + account = model.new + assert_not_predicate account, :valid? + assert_equal [{ error: :blank }], account.errors.details[:company] + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + end + + def test_default + david = developers(:david) + jamis = developers(:jamis) + + model = Class.new(ActiveRecord::Base) do + self.table_name = "ships" + def self.name; "Temp"; end + belongs_to :developer, default: -> { david } + end + + ship = model.create! + assert_equal david, ship.developer + + ship = model.create!(developer: jamis) + assert_equal jamis, ship.developer + + ship.update!(developer: nil) + assert_equal david, ship.developer + end + + def test_default_with_lambda + model = Class.new(ActiveRecord::Base) do + self.table_name = "ships" + def self.name; "Temp"; end + belongs_to :developer, default: -> { default_developer } + + def default_developer + Developer.first + end + end + + ship = model.create! + assert_equal developers(:david), ship.developer + + ship = model.create!(developer: developers(:jamis)) + assert_equal developers(:jamis), ship.developer + end + + def test_default_scope_on_relations_is_not_cached + counter = 0 + + comments = Class.new(ActiveRecord::Base) { + self.table_name = "comments" + self.inheritance_column = "not_there" + + posts = Class.new(ActiveRecord::Base) { + self.table_name = "posts" + self.inheritance_column = "not_there" + + default_scope -> { + counter += 1 + where("id = :inc", inc: counter) + } + + has_many :comments, anonymous_class: comments + } + belongs_to :post, anonymous_class: posts, inverse_of: false + } + + assert_equal 0, counter + comment = comments.first + assert_equal 0, counter + sql = capture_sql { comment.post } + comment.reload + assert_not_equal sql, capture_sql { comment.post } + 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_raises_type_mismatch_with_namespaced_class + assert_nil defined?(Region), "This test requires that there is no top-level Region class" + + ActiveRecord::Base.connection.instance_eval do + create_table(:admin_regions) { |t| t.string :name } + add_column :admin_users, :region_id, :integer + end + Admin.const_set "RegionalUser", Class.new(Admin::User) { belongs_to(:region) } + Admin.const_set "Region", Class.new(ActiveRecord::Base) + + e = assert_raise(ActiveRecord::AssociationTypeMismatch) { + Admin::RegionalUser.new(region: "wrong value") + } + assert_match(/^Region\([^)]+\) expected, got "wrong value" which is an instance of String\([^)]+\)$/, e.message) + ensure + Admin.send :remove_const, "Region" if Admin.const_defined?("Region") + Admin.send :remove_const, "RegionalUser" if Admin.const_defined?("RegionalUser") + + ActiveRecord::Base.connection.instance_eval do + remove_column :admin_users, :region_id if column_exists?(:admin_users, :region_id) + drop_table :admin_regions, if_exists: true + end + + Admin::User.reset_column_information + 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_predicate citibank_result.association(:firm_with_primary_key), :loaded? + 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_predicate citibank_result.association(:firm_with_primary_key_symbols), :loaded? + 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_from_new_record + citibank = Account.new("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_predicate 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_predicate client.account, :new_record? + end + + def test_reloading_the_belonging_object + odegy_account = accounts(:odegy_account) + + assert_equal "Odegy", odegy_account.firm.name + Company.where(id: odegy_account.firm_id).update_all(name: "ODEGY") + assert_equal "Odegy", odegy_account.firm.name + + assert_equal "ODEGY", odegy_account.reload_firm.name + end + + def test_reload_the_belonging_object_with_query_cache + odegy_account_id = accounts(:odegy_account).id + + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + # Populate the cache with a query + odegy_account = Account.find(odegy_account_id) + + # Populate the cache with a second query + odegy_account.firm + + assert_equal 2, connection.query_cache.size + + # Clear the cache and fetch the firm again, populating the cache with a query + assert_queries(1) { odegy_account.reload_firm } + + # This query is not cached anymore, so it should make a real SQL query + assert_queries(1) { Account.find(odegy_account_id) } + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + + def test_natural_assignment_to_nil + client = Client.find(3) + client.firm = nil + client.save + client.association(:firm).reload + assert_nil client.firm + 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 + client.association(:firm_with_primary_key).reload + assert_nil client.firm_with_primary_key + 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.association(:sponsorable).reload + assert_nil sponsor.sponsorable + + sponsor.sponsorable_type = "" # the column doesn't have to be declared NOT NULL + assert_nil sponsor.association(:sponsorable).send(:klass) + sponsor.association(:sponsorable).reload + assert_nil sponsor.sponsorable + + sponsor.sponsorable = Member.new name: "Bert" + assert_equal Member, sponsor.association(:sponsorable).send(:klass) + 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_without_counter_cache_option + # Ship has a conventionally named `treasures_count` column, but the counter_cache + # option is not given on the association. + ship = Ship.create(name: "Countless") + + assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed unless counter_cache is given on the relation" do + treasure = Treasure.new(name: "Gold", ship: ship) + treasure.save + end + + assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed unless counter_cache is given on the relation" do + treasure = ship.treasures.first + treasure.destroy + end + 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 + topic = Topic.create!(title: "debate") + reply = Reply.create!(title: "blah!", content: "world around!", topic: topic) + + assert_equal topic.id, reply.parent_id + assert_equal 1, topic.reload.replies.size + + reply.topic = nil + reply.reload + + assert_equal topic.id, reply.parent_id + assert_equal 1, topic.reload.replies.size + + reply.topic = nil + reply.save! + + assert_equal 0, topic.reload.replies.size + end + + def test_belongs_to_counter_with_assigning_new_object + topic = Topic.create!(title: "debate") + reply = Reply.create!(title: "blah!", content: "world around!", topic: topic) + + assert_equal topic.id, reply.parent_id + assert_equal 1, topic.reload.replies_count + + topic2 = reply.build_topic(title: "debate2") + reply.save! + + assert_not_equal topic.id, reply.parent_id + assert_equal topic2.id, reply.parent_id + + assert_equal 0, topic.reload.replies_count + assert_equal 1, topic2.reload.replies_count + 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" => "debate2") + + assert_equal 0, debate.reload.replies_count + assert_equal 1, debate2.reload.replies_count + + reply.parent_title = "debate" + reply.save! + + assert_equal 1, debate.reload.replies_count + assert_equal 0, debate2.reload.replies_count + + assert_no_queries do + reply.topic_with_primary_key = debate + end + + assert_equal 1, debate.reload.replies_count + assert_equal 0, debate2.reload.replies_count + + reply.topic_with_primary_key = debate2 + reply.save! + + assert_equal 0, debate.reload.replies_count + assert_equal 1, debate2.reload.replies_count + + reply.topic_with_primary_key = nil + reply.save! + + 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 + reply1.save! + + assert_equal 0, Topic.find(topic1.id).replies.size + assert_equal 0, Topic.find(topic2.id).replies.size + + reply1.topic = topic1 + reply1.save! + + 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") + + assert_queries(2) do + topic.replies.create!(title: "re: monday night", content: "football") + end + + 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_counter_after_touch + topic = Topic.create!(title: "topic") + + assert_equal 0, topic.replies_count + assert_equal 0, topic.after_touch_called + + reply = Reply.create!(title: "blah!", content: "world around!", topic_with_primary_key: topic) + + assert_equal 1, topic.replies_count + assert_equal 1, topic.after_touch_called + + reply.destroy! + + assert_equal 0, topic.replies_count + assert_equal 2, topic.after_touch_called + end + + def test_belongs_to_touch_with_reassigning + debate = Topic.create!(title: "debate") + debate2 = Topic.create!(title: "debate2") + reply = Reply.create!(title: "blah!", content: "world around!", parent_title: "debate2") + + time = 1.day.ago + + debate.touch(time: time) + debate2.touch(time: time) + + assert_queries(3) do + reply.parent_title = "debate" + reply.save! + end + + assert_operator debate.reload.updated_at, :>, time + assert_operator debate2.reload.updated_at, :>, time + + debate.touch(time: time) + debate2.touch(time: time) + + assert_queries(3) do + reply.topic_with_primary_key = debate2 + reply.save! + end + + assert_operator debate.reload.updated_at, :>, time + assert_operator debate2.reload.updated_at, :>, time + 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_on_multiple_records + line_item = LineItem.create!(amount: 1) + line_item2 = LineItem.create!(amount: 2) + Invoice.create!(line_items: [line_item, line_item2]) + + assert_queries(1) do + LineItem.transaction do + line_item.touch + line_item2.touch + end + end + + assert_queries(2) do + line_item.touch + line_item2.touch + end + 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 + travel(1.second) do + line_item.touch + end + + 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_no_queries { 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_not_predicate final_cut, :persisted? + assert final_cut.save + assert_predicate final_cut, :persisted? + assert_predicate firm, :persisted? + assert_equal firm, final_cut.firm + final_cut.association(:firm).reload + assert_equal firm, final_cut.firm + 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_not_predicate final_cut, :persisted? + assert final_cut.save + assert_predicate final_cut, :persisted? + assert_predicate firm, :persisted? + assert_equal firm, final_cut.firm_with_primary_key + final_cut.association(:firm_with_primary_key).reload + assert_equal firm, final_cut.firm_with_primary_key + end + + def test_new_record_with_foreign_key_but_no_object + client = Client.new("firm_id" => 1) + assert_equal Firm.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_no_queries { tagging.super_tag } + end + + def test_dont_find_target_when_saving_foreign_key_after_stale_association_loaded + client = Client.create!(name: "Test client", firm_with_basic_id: Firm.find(1)) + client.firm_id = Firm.create!(name: "Test firm").id + assert_queries(1) { client.save! } + 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 + reply.save! + + 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 + silly.save! + + 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_replace_counter_cache + topic = Topic.create(title: "Zoom-zoom-zoom") + reply = Reply.create(title: "re: zoom", content: "speedy quick!") + + reply.topic = topic + reply.save + topic.reload + + assert_equal 1, topic.replies_count + 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_predicate companies(:first_client).readonly_firm, :readonly? + end + + def 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 + + class DestroyableBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "UndestroyableAuthor", dependent: :destroy + end + + class UndestroyableAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "DestroyableBook", foreign_key: "author_id" + before_destroy :dont + + def dont + throw(:abort) + end + end + + def test_dependency_should_halt_parent_destruction + author = UndestroyableAuthor.create!(name: "Test") + book = DestroyableBook.create!(author: author) + + assert_no_difference ["UndestroyableAuthor.count", "DestroyableBook.count"] do + assert_not book.destroy + end + 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_not_predicate firm_proxy, :stale_target? + assert_not_predicate 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_predicate firm_proxy, :stale_target? + assert_predicate 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_not_predicate proxy, :stale_target? + assert_equal members(:groucho), sponsor.sponsorable + + sponsor.sponsorable_id = members(:some_other_guy).id + + assert_predicate 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_not_predicate proxy, :stale_target? + assert_equal members(:groucho), sponsor.sponsorable + + sponsor.sponsorable_type = "Firm" + + assert_predicate 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_equal post.id, comment.id + + assert_difference "post.reload.tags_count", -1 do + assert_difference "comment.reload.tags_count", +1 do + tagging.taggable = comment + tagging.save! + end + end + + assert_difference "comment.reload.tags_count", -1 do + assert_difference "post.reload.tags_count", +1 do + tagging.taggable_type = post.class.polymorphic_name + tagging.taggable_id = post.id + tagging.save! + 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_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_belongs_to_with_out_of_range_value_assigning + model = Class.new(Comment) do + def self.name; "Temp"; end + validates :post, presence: true + end + + comment = model.new + comment.post_id = 9223372036854775808 # out of range in the bigint + + assert_nil comment.post + assert_not_predicate comment, :valid? + assert_equal [{ error: :blank }], comment.errors.details[:post] + 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_not_predicate 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 + + def test_multiple_counter_cache_with_after_create_update + post = posts(:welcome) + parent = comments(:greetings) + + assert_difference "parent.reload.children_count", +1 do + assert_difference "post.reload.comments_count", +1 do + CommentWithAfterCreateUpdate.create(body: "foo", post: post, parent: parent) + end + end + 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/bidirectional_destroy_dependencies_test.rb b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb new file mode 100644 index 0000000000..88221b012e --- /dev/null +++ b/activerecord/test/cases/associations/bidirectional_destroy_dependencies_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/content" + +class BidirectionalDestroyDependenciesTest < ActiveRecord::TestCase + fixtures :content, :content_positions + + def setup + Content.destroyed_ids.clear + ContentPosition.destroyed_ids.clear + end + + def test_bidirectional_dependence_when_destroying_item_with_belongs_to_association + content_position = ContentPosition.find(1) + content = content_position.content + assert_not_nil content + + content_position.destroy + + assert_equal [content_position.id], ContentPosition.destroyed_ids + assert_equal [content.id], Content.destroyed_ids + end + + def test_bidirectional_dependence_when_destroying_item_with_has_one_association + content = Content.find(1) + content_position = content.content_position + assert_not_nil content_position + + content.destroy + + assert_equal [content.id], Content.destroyed_ids + assert_equal [content_position.id], ContentPosition.destroyed_ids + end + + def test_bidirectional_dependence_when_destroying_item_with_has_one_association_fails_first_time + content = ContentWhichRequiresTwoDestroyCalls.find(1) + + 2.times { content.destroy } + + assert_equal content.destroyed?, true + 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..25d55dc4c9 --- /dev/null +++ b/activerecord/test/cases/associations/callbacks_test.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/author" +require "models/project" +require "models/developer" +require "models/computer" +require "models/company" + +class AssociationCallbacksTest < ActiveRecord::TestCase + fixtures :posts, :authors, :author_addresses, :projects, :developers + + def setup + @david = authors(:david) + @thinking = posts(:thinking) + @authorless = posts(:authorless) + assert_empty @david.post_log + 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_empty ar.developers_log + 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_predicate alice, :new_record? + end + + def test_has_and_belongs_to_many_after_add_called_after_save + ar = projects(:active_record) + assert_empty ar.developers_log + 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_empty activerecord.developers_log + 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_empty activerecord.developers_log + 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_empty activerecord.developers_log + 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_not_includes @david.unchangeable_posts, @authorless + begin + @david.unchangeable_posts << @authorless + rescue Exception + end + assert_empty @david.post_log + assert_not_includes @david.unchangeable_posts, @authorless + @david.reload + assert_not_includes @david.unchangeable_posts, @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..a9e22c7643 --- /dev/null +++ b/activerecord/test/cases/associations/cascaded_eager_loading_test.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +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, :author_addresses, :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 + authors = Author.joins(:posts).eager_load(:comments).where(posts: { tags_count: 1 }).to_a + assert_equal 3, 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_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 + reply = Reply.new(title: "gaga", content: "boo-boo", parent_id: 1) + assert reply.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_includes replies, topics(:second) + assert_not_includes replies, 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_preload_through_missing_records + post = Post.where.not(author_id: Author.select(:id)).preload(author: { comments: :post }).first! + assert_no_queries { assert_nil post.author } + end + + def test_eager_association_loading_with_missing_first_record + posts = Post.where(id: 3).preload(author: { comments: :post }).to_a + assert_equal posts.size, 1 + 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/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..5fca972aee --- /dev/null +++ b/activerecord/test/cases/associations/eager_load_includes_full_sti_class_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +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" + + def self.polymorphic_name + sti_name + end + end +end + +module PolymorphicFullStiClassNamesSharedTest + def setup + @old_store_full_sti_class = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + + post = Namespaced::Post.create(title: "Great stuff", body: "This is not", author_id: 1) + @tagging = Tagging.create(taggable: post) + end + + def teardown + ActiveRecord::Base.store_full_sti_class = @old_store_full_sti_class + end + + def test_class_names + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + post = Namespaced::Post.find_by_title("Great stuff") + assert_nil post.tagging + + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + post = Namespaced::Post.find_by_title("Great stuff") + assert_equal @tagging, post.tagging + end + + def test_class_names_with_includes + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + post = Namespaced::Post.includes(:tagging).find_by_title("Great stuff") + assert_nil post.tagging + + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + post = Namespaced::Post.includes(:tagging).find_by_title("Great stuff") + assert_equal @tagging, post.tagging + end + + def test_class_names_with_eager_load + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + post = Namespaced::Post.eager_load(:tagging).find_by_title("Great stuff") + assert_nil post.tagging + + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + post = Namespaced::Post.eager_load(:tagging).find_by_title("Great stuff") + assert_equal @tagging, post.tagging + end + + def test_class_names_with_find_by + post = Namespaced::Post.find_by_title("Great stuff") + + ActiveRecord::Base.store_full_sti_class = !store_full_sti_class + assert_nil Tagging.find_by(taggable: post) + + ActiveRecord::Base.store_full_sti_class = store_full_sti_class + assert_equal @tagging, Tagging.find_by(taggable: post) + end +end + +class PolymorphicFullStiClassNamesTest < ActiveRecord::TestCase + include PolymorphicFullStiClassNamesSharedTest + + private + def store_full_sti_class + true + end +end + +class PolymorphicNonFullStiClassNamesTest < ActiveRecord::TestCase + include PolymorphicFullStiClassNamesSharedTest + + private + def store_full_sti_class + false + 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..525ad3197a --- /dev/null +++ b/activerecord/test/cases/associations/eager_load_nested_include_test.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +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 + private + 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(&:delete_all) + 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_no_queries 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..420a5a805b --- /dev/null +++ b/activerecord/test/cases/associations/eager_singularization_test.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "cases/helper" + +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 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 + + private + def connection + ActiveRecord::Base.connection + 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..b37e59038e --- /dev/null +++ b/activerecord/test/cases/associations/eager_test.rb @@ -0,0 +1,1625 @@ +# frozen_string_literal: true + +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/citation" +require "models/developer" +require "models/computer" +require "models/project" +require "models/member" +require "models/membership" +require "models/club" +require "models/categorization" +require "models/sponsor" +require "models/mentor" +require "models/contract" + +class EagerLoadingTooManyIdsTest < ActiveRecord::TestCase + fixtures :citations + + def test_preloading_too_many_ids + assert_equal Citation.count, Citation.preload(:reference_of).to_a.size + end + + def test_eager_loading_too_may_ids + assert_equal Citation.count, Citation.eager_load(:citations).offset(0).size + end +end + +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_should_work_inverse_of_with_eager_load + author = authors(:david) + assert_same author, author.posts.first.author + assert_same author, author.posts.eager_load(:comments).first.author + 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_includes post.comments, comments(:greetings) + + post = Post.all.merge!(includes: :comments, where: "posts.title = 'Welcome to the weblog'").first + assert_equal 2, post.comments.size + assert_includes post.comments, 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_loading_with_scope_including_joins + member = Member.first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.general_club + + member = Member.preload(:general_club).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.general_club + + member = Member.eager_load(:general_club).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.general_club + end + + def test_loading_association_with_same_table_joins + super_memberships = [memberships(:super_membership_of_boring_club)] + + member = Member.joins(:favourite_memberships).first + assert_equal members(:groucho), member + assert_equal super_memberships, member.super_memberships + + member = Member.joins(:favourite_memberships).preload(:super_memberships).first + assert_equal members(:groucho), member + assert_equal super_memberships, member.super_memberships + + member = Member.joins(:favourite_memberships).eager_load(:super_memberships).first + assert_equal members(:groucho), member + assert_equal super_memberships, member.super_memberships + end + + def test_loading_association_with_intersection_joins + member = Member.joins(:current_membership).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.club + assert_equal memberships(:membership_of_boring_club), member.current_membership + + member = Member.joins(:current_membership).preload(:club, :current_membership).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.club + assert_equal memberships(:membership_of_boring_club), member.current_membership + + member = Member.joins(:current_membership).eager_load(:club, :current_membership).first + assert_equal members(:groucho), member + assert_equal clubs(:boring_club), member.club + assert_equal memberships(:membership_of_boring_club), member.current_membership + end + + def test_loading_associations_dont_leak_instance_state + assertions = ->(firm) { + assert_equal companies(:first_firm), firm + + assert_predicate firm.association(:readonly_account), :loaded? + assert_predicate firm.association(:accounts), :loaded? + + assert_equal accounts(:signals37), firm.readonly_account + assert_equal [accounts(:signals37)], firm.accounts + + assert_predicate firm.readonly_account, :readonly? + assert firm.accounts.none?(&:readonly?) + } + + assertions.call(Firm.preload(:readonly_account, :accounts).first) + assertions.call(Firm.eager_load(:readonly_account, :accounts).first) + 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 authors.count > 0 + assert_no_queries { authors.map(&:favorite_authors) } + end + + def test_eager_loaded_has_one_association_with_references_does_not_run_additional_queries + Post.update_all(author_id: nil) + authors = Author.includes(:post).references(:post).to_a + assert authors.count > 0 + assert_no_queries { authors.map(&:post) } + end + + def test_calculate_with_string_in_from_and_eager_loading + assert_equal 10, Post.from("authors, posts").eager_load(:comments).where("posts.author_id = authors.id").count + 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_includes posts.first.comments, 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 + assert_called(Comment.connection, :in_clause_length, returns: 5) do + posts = Post.all.merge!(includes: :comments).to_a + assert_equal 11, posts.size + end + end + + def test_preloading_has_many_in_one_queries_when_database_has_no_limit_on_ids_it_can_handle + assert_called(Comment.connection, :in_clause_length, returns: nil) do + posts = Post.all.merge!(includes: :comments).to_a + assert_equal 11, posts.size + end + end + + def test_preloading_habtm_in_multiple_queries_with_more_ids_than_database_can_handle + assert_called(Comment.connection, :in_clause_length, times: 2, returns: 5) do + posts = Post.all.merge!(includes: :categories).to_a + assert_equal 11, posts.size + end + end + + def test_preloading_habtm_in_one_queries_when_database_has_no_limit_on_ids_it_can_handle + assert_called(Comment.connection, :in_clause_length, times: 2, returns: nil) do + posts = Post.all.merge!(includes: :categories).to_a + assert_equal 11, posts.size + end + end + + def test_load_associated_records_in_one_query_when_adapter_has_no_limit + assert_called(Comment.connection, :in_clause_length, returns: nil) do + post = posts(:welcome) + assert_queries(2) do + Post.includes(:comments).where(id: post.id).to_a + end + end + end + + def test_load_associated_records_in_several_queries_when_many_ids_passed + assert_called(Comment.connection, :in_clause_length, returns: 1) do + post1, post2 = posts(:welcome), posts(:thinking) + assert_queries(3) do + Post.includes(:comments).where(id: [post1.id, post2.id]).to_a + end + end + end + + def test_load_associated_records_in_one_query_when_a_few_ids_passed + assert_called(Comment.connection, :in_clause_length, returns: 3) do + post = posts(:welcome) + assert_queries(2) do + Post.includes(:comments).where(id: post.id).to_a + end + 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_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_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_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_not_empty Author.all.merge!(includes: :hello_posts_with_hash_conditions).find(authors(:david).id).hello_posts + 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_three_level_nested_preloading_does_not_raise_exception_when_association_does_not_exist + post_id = Comment.where(author_id: nil).where.not(post_id: nil).first.post_id + + assert_nothing_raised do + Post.preload(comments: [{ author: :essays }]).find(post_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_includes titles, posts(:welcome).title + assert_includes titles, 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(&: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(&: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(&: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(&: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(&: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(&: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(Arel.sql(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(&: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(&: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_preloading_has_many_through_with_implicit_source + authors = Author.includes(:very_special_comments).to_a + assert_no_queries do + special_comment_authors = authors.map { |author| [author.name, author.very_special_comments.size] } + assert_equal [["David", 1], ["Mary", 0], ["Bob", 0]], special_comment_authors + end + end + + def test_eager_with_has_many_through_an_sti_join_model_with_conditions_on_both + author = Author.all.merge!(includes: :special_nonexistent_post_comments, order: "authors.id").first + assert_equal [], author.special_nonexistent_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(&: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(&: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_includes posts[0].categories, categories(:technology) + assert_includes posts[1].categories, 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_includes posts[0].categories, categories(:technology) + assert_includes posts[1].categories, 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 + e = assert_raise(ActiveRecord::AssociationNotFoundError) { + Post.all.merge!(includes: :monkeys).find(6) + } + assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message) + + e = assert_raise(ActiveRecord::AssociationNotFoundError) { + Post.all.merge!(includes: [ :monkeys ]).find(6) + } + assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message) + + e = assert_raise(ActiveRecord::AssociationNotFoundError) { + Post.all.merge!(includes: [ "monkeys" ]).find(6) + } + assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message) + + e = assert_raise(ActiveRecord::AssociationNotFoundError) { + Post.all.merge!(includes: [ :monkeys, :elephants ]).find(6) + } + assert_equal("Association named 'monkeys' was not found on Post; perhaps you misspelled it?", e.message) + end + + def test_eager_has_many_through_with_order + tag = OrderedTag.create(name: "Foo") + post1 = Post.create!(title: "Beaches", body: "I like beaches!") + post2 = Post.create!(title: "Pools", body: "I like pools!") + + Tagging.create!(taggable_type: "Post", taggable_id: post1.id, tag: tag) + Tagging.create!(taggable_type: "Post", taggable_id: post2.id, tag: tag) + + tag_with_includes = OrderedTag.includes(:tagged_posts).find(tag.id) + assert_equal tag_with_includes.ordered_taggings.map(&:taggable).map(&:title), tag_with_includes.tagged_posts.map(&:title) + end + + def test_eager_has_many_through_multiple_with_order + tag1 = OrderedTag.create!(name: "Bar") + tag2 = OrderedTag.create!(name: "Foo") + + post1 = Post.create!(title: "Beaches", body: "I like beaches!") + post2 = Post.create!(title: "Pools", body: "I like pools!") + + Tagging.create!(taggable: post1, tag: tag1) + Tagging.create!(taggable: post2, tag: tag1) + Tagging.create!(taggable: post2, tag: tag2) + Tagging.create!(taggable: post1, tag: tag2) + + tags_with_includes = OrderedTag.where(id: [tag1, tag2].map(&:id)).includes(:tagged_posts).order(:id).to_a + tag1_with_includes = tags_with_includes.first + tag2_with_includes = tags_with_includes.last + + assert_equal([post2, post1].map(&:title), tag1_with_includes.tagged_posts.map(&:title)) + assert_equal([post1, post2].map(&:title), tag2_with_includes.tagged_posts.map(&:title)) + 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_class_method_using_find_method + david = developers(:david) + developer = EagerDeveloperWithClassMethodDefaultScope.find(david.id) + 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_using_find_by_method + developer = EagerDeveloperWithClassMethodDefaultScope.find_by(name: "David") + 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 test_limited_eager_with_order + assert_equal( + posts(:thinking, :sti_comments), + Post.all.merge!( + includes: [:author, :comments], where: { "authors.name" => "David" }, + order: Arel.sql("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: Arel.sql("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: [Arel.sql("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: [Arel.sql("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_polymorphic_type_condition + post = Post.all.merge!(includes: :taggings).find(posts(:thinking).id) + assert_includes post.taggings, taggings(:thinking_general) + post = SpecialPost.all.merge!(includes: :taggings).find(posts(:thinking).id) + assert_includes post.taggings, 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]) + if d1[i].account.nil? + assert_nil(d2[i].account) + else + assert_equal(d1[i].account, d2[i].account) + end + 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 do |type| + if (expected = d1[i].send(type)).nil? + assert_nil(d2[i].send(type)) + else + assert_equal(expected, d2[i].send(type)) + end + end + 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_eager_association_with_scope_with_joins + assert_nothing_raised do + Post.includes(:very_special_comment_with_post_with_joins).to_a + end + 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_association_loading_notification + notifications = messages_for("instantiation.active_record") do + Developer.all.merge!(includes: "projects", where: { "developers_projects.access_level" => 1 }, limit: 5).to_a.size + end + + message = notifications.first + payload = message.last + count = Developer.all.merge!(includes: "projects", where: { "developers_projects.access_level" => 1 }, limit: 5).to_a.size + + # eagerloaded row count should be greater than just developer count + assert_operator payload[:record_count], :>, count + assert_equal Developer.name, payload[:class_name] + end + + def test_base_messages + notifications = messages_for("instantiation.active_record") do + Developer.all.to_a + end + message = notifications.first + payload = message.last + + assert_equal Developer.all.to_a.count, payload[:record_count] + assert_equal Developer.name, payload[:class_name] + end + + def messages_for(name) + notifications = [] + ActiveSupport::Notifications.subscribe(name) do |*args| + notifications << args + end + yield + notifications + ensure + ActiveSupport::Notifications.unsubscribe(name) + 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!(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 } + assert_equal c.client_of, client.client_of + 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 } + assert_equal t.taggable_id, tagging.taggable_id + 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_distinct + 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_has_one_using_reorder + klass = Class.new(ActiveRecord::Base) do + def self.name; "TempAuthor"; end + self.table_name = "authors" + has_one :post, class_name: "PostWithDefaultScope", foreign_key: :author_id + has_one :reorderd_post, -> { reorder(title: :desc) }, class_name: "PostWithDefaultScope", foreign_key: :author_id + end + + author = klass.first + # PRECONDITION: make sure ordering results in different results + assert_not_equal author.post, author.reorderd_post + + preloaded_reorderd_post = klass.preload(:reorderd_post).first.reorderd_post + + assert_equal author.reorderd_post, preloaded_reorderd_post + assert_equal Post.order(title: :desc).first.title, preloaded_reorderd_post.title + 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_no_queries 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 do + Post.includes(:comments).order("").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 + + def test_eager_load_multiple_associations_with_references + mentor = Mentor.create!(name: "Barış Can DAYLIK") + developer = Developer.create!(name: "Mehmet Emin İNAÇ", mentor: mentor) + Contract.create!(developer: developer) + project = Project.create!(name: "VNGRS", mentor: mentor) + project.developers << developer + projects = Project.references(:mentors).includes(mentor: { developers: :contracts }, developers: :contracts) + assert_equal projects.last.mentor.developers.first.contracts, projects.last.developers.last.contracts + end + + def test_preloading_has_many_through_with_custom_scope + project = Project.includes(:developers_named_david_with_hash_conditions).find(projects(:active_record).id) + assert_equal [developers(:david)], project.developers_named_david_with_hash_conditions + 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? + authors(:david).essays.includes(:writer).exists? + authors(:david).essays.includes(:owner).where("name IS NOT NULL").exists? + 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 "preloading and eager loading of instance dependent associations is not supported" do + message = "association scope 'posts_with_signature' is" + error = assert_raises(ArgumentError) do + Author.includes(:posts_with_signature).to_a + end + assert_match message, error.message + + error = assert_raises(ArgumentError) do + Author.preload(:posts_with_signature).to_a + end + assert_match message, error.message + + error = assert_raises(ArgumentError) do + Author.eager_load(:posts_with_signature).to_a + end + assert_match message, error.message + end + + test "preload with invalid argument" do + exception = assert_raises(ArgumentError) do + Author.preload(10).to_a + end + assert_equal("10 was not recognized for preload", exception.message) + end + + test "associations with extensions are not instance dependent" do + assert_nothing_raised do + Author.includes(:posts_with_extension).to_a + end + end + + test "including associations with extensions and an instance dependent scope is not supported" do + e = assert_raises(ArgumentError) do + Author.includes(:posts_with_extension_and_instance).to_a + end + assert_match(/Preloading instance dependent scopes is not supported/, e.message) + end + + test "preloading readonly association" do + # has-one + firm = Firm.where(id: "1").preload(:readonly_account).first! + assert_predicate firm.readonly_account, :readonly? + + # has_and_belongs_to_many + project = Project.where(id: "2").preload(:readonly_developers).first! + assert_predicate project.readonly_developers.first, :readonly? + + # has-many :through + david = Author.where(id: "1").preload(:readonly_comments).first! + assert_predicate david.readonly_comments.first, :readonly? + end + + test "eager-loading non-readonly association" do + # has_one + firm = Firm.where(id: "1").eager_load(:account).first! + assert_not_predicate firm.account, :readonly? + + # has_and_belongs_to_many + project = Project.where(id: "2").eager_load(:developers).first! + assert_not_predicate project.developers.first, :readonly? + + # has_many :through + david = Author.where(id: "1").eager_load(:comments).first! + assert_not_predicate david.comments.first, :readonly? + + # belongs_to + post = Post.where(id: "1").eager_load(:author).first! + assert_not_predicate post.author, :readonly? + end + + test "eager-loading readonly association" do + # has-one + firm = Firm.where(id: "1").eager_load(:readonly_account).first! + assert_predicate firm.readonly_account, :readonly? + + # has_and_belongs_to_many + project = Project.where(id: "2").eager_load(:readonly_developers).first! + assert_predicate project.readonly_developers.first, :readonly? + + # has-many :through + david = Author.where(id: "1").eager_load(:readonly_comments).first! + assert_predicate david.readonly_comments.first, :readonly? + + # belongs_to + post = Post.where(id: "1").eager_load(:readonly_author).first! + assert_predicate post.readonly_author, :readonly? + end + + test "preloading a polymorphic association with references to the associated table" do + post = Post.includes(:tags).references(:tags).where("tags.name = ?", "General").first + assert_equal posts(:welcome), post + end + + test "eager-loading a polymorphic association with references to the associated table" do + post = Post.eager_load(:tags).where("tags.name = ?", "General").first + assert_equal posts(:welcome), post + end + + test "eager-loading with a polymorphic association won't work consistently" do + assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).to_a } + assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).count } + assert_raise(ActiveRecord::EagerLoadPolymorphicError) { authors(:david).essays.eager_load(:writer).exists? } + end + + # CollectionProxy#reader is expensive, so the preloader avoids calling it. + test "preloading has_many_through association avoids calling association.reader" do + assert_not_called_on_instance_of(ActiveRecord::Associations::HasManyAssociation, :reader) do + Author.preload(:readonly_comments).first! + end + end + + test "preloading through a polymorphic association doesn't require the association to exist" do + sponsors = [] + assert_queries 5 do + sponsors = Sponsor.where(sponsorable_id: 1).preload(sponsorable: [:post, :membership]).to_a + end + # check the preload worked + assert_queries 0 do + sponsors.map(&:sponsorable).map { |s| s.respond_to?(:posts) ? s.post.author : s.membership } + end + end + + test "preloading a regular association through a polymorphic association doesn't require the association to exist on all types" do + sponsors = [] + assert_queries 6 do + sponsors = Sponsor.where(sponsorable_id: 1).preload(sponsorable: [{ post: :first_comment }, :membership]).to_a + end + # check the preload worked + assert_queries 0 do + sponsors.map(&:sponsorable).map { |s| s.respond_to?(:posts) ? s.post.author : s.membership } + end + end + + test "preloading a regular association with a typo through a polymorphic association still raises" do + # this test contains an intentional typo of first -> fist + assert_raises(ActiveRecord::AssociationNotFoundError) do + Sponsor.where(sponsorable_id: 1).preload(sponsorable: [{ post: :fist_comment }, :membership]).to_a + end + end + + private + def find_all_ordered(klass, include = nil) + klass.order("#{klass.table_name}.#{klass.primary_key}").includes(include).to_a + 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..aef8f31112 --- /dev/null +++ b/activerecord/test/cases/associations/extension_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" +require "models/project" +require "models/developer" +require "models/computer" +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_extension_with_dirty_target + comment = posts(:welcome).comments.build(body: "New comment") + assert_equal comment, posts(:welcome).comments.with_content("New comment") + 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 + + def test_association_with_default_scope + assert_raises OopsError do + posts(:welcome).comments.destroy_all + end + end + + private + + def extend!(model) + ActiveRecord::Associations::Builder::HasMany.define_extensions(model, :association_name) { } + 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..fe8bdd03ba --- /dev/null +++ b/activerecord/test/cases/associations/has_and_belongs_to_many_associations_test.rb @@ -0,0 +1,1024 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/computer" +require "models/project" +require "models/company" +require "models/course" +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/professor" +require "models/treasure" +require "models/price_estimate" +require "models/club" +require "models/user" +require "models/member" +require "models/membership" +require "models/sponsor" +require "models/lesson" +require "models/student" +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 DeveloperWithSymbolClassName < Developer + has_and_belongs_to_many :projects, class_name: :ProjectWithSymbolsForKeys +end + +class DeveloperWithExtendOption < Developer + module NamedExtension + def category + "sns" + end + end + + has_and_belongs_to_many :projects, extend: NamedExtension +end + +class ProjectUnscopingDavidDefaultScope < ActiveRecord::Base + self.table_name = "projects" + has_and_belongs_to_many :developers, -> { unscope(where: "name") }, + class_name: "LazyBlockDeveloperCalledDavid", + join_table: "developers_projects", + foreign_key: "project_id", + association_foreign_key: "developer_id" +end + +class Kitchen < ActiveRecord::Base + has_one :sink +end + +class Sink < ActiveRecord::Base + has_and_belongs_to_many :sources, join_table: :edges + belongs_to :kitchen + accepts_nested_attributes_for :kitchen +end + +class Source < ActiveRecord::Base + self.table_name = "men" + has_and_belongs_to_many :sinks, join_table: :edges +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, :computers + + 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_join_table_composite_primary_key_should_not_warn + country = Country.new(name: "India") + country.country_id = "c1" + country.save! + + treaty = Treaty.new(name: "peace") + treaty.treaty_id = "t1" + warning = capture(:stderr) do + country.treaties << treaty + end + assert_no_match(/WARNING: Active Record does not support composite primary key\./, warning) + end + + def test_has_and_belongs_to_many + david = Developer.find(1) + + assert_not_empty david.projects + assert_equal 2, david.projects.size + + active_record = Project.find(1) + assert_not_empty active_record.developers + assert_equal 3, active_record.developers.size + assert_includes active_record.developers, 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.reload.size + assert_equal 2, action_controller.developers.reload.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.reload.size + assert_equal 2, action_controller.developers.size + assert_equal 2, action_controller.developers.reload.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.reload.size + assert_equal 2, action_controller.developers.size + assert_equal 2, action_controller.developers.reload.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.reload.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.reload.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_not_predicate aredridel, :persisted? + assert_not_predicate p, :persisted? + assert aredridel.save + assert_predicate 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.reload.size + end + + def test_habtm_saving_multiple_relationships + new_project = Project.new("name" => "Grimetime") + amount_of_developers = 4 + developers = (0...amount_of_developers).reverse_each.map { |i| Developer.create(name: "JME #{i}") } + + 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_distinct_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) + + # Load schema information so we don't query below if running just this test. + Project.define_attribute_methods + + proj = assert_no_queries { devel.projects.build("name" => "Projekt") } + assert_not_predicate devel.projects, :loaded? + + assert_equal devel.projects.last, proj + assert_predicate devel.projects, :loaded? + + assert_not_predicate proj, :persisted? + devel.save + assert_predicate 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) + + # Load schema information so we don't query below if running just this test. + Project.define_attribute_methods + + proj = assert_no_queries { devel.projects.new("name" => "Projekt") } + assert_not_predicate devel.projects, :loaded? + + assert_equal devel.projects.last, proj + assert_predicate devel.projects, :loaded? + + assert_not_predicate proj, :persisted? + devel.save + assert_predicate 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_not_predicate proj2, :persisted? + devel.save + assert_predicate devel, :persisted? + assert_predicate 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_not_predicate devel.projects, :loaded? + + assert_equal devel.projects.last, proj + assert_not_predicate devel.projects, :loaded? + + assert_predicate proj, :persisted? + assert_equal Developer.find(1).projects.sort_by(&:id).last, proj # 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_predicate another_post, :persisted? + assert_equal "Yet Another Testing Title", another_post.title + end + + def test_distinct_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.uniq.size + end + + def test_distinct_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_distinct_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_distinct_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.reload.size + assert_equal 2, active_record.developers.reload.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.reload.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.reload.size + end + + def test_removing_associations_on_destroy + david = DeveloperWithBeforeDestroyRaise.find(1) + assert_not_empty david.projects + david.destroy + assert_empty david.projects + assert_empty DeveloperWithBeforeDestroyRaise.connection.select_all("SELECT * FROM developers_projects WHERE developer_id = 1") + 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_empty join_records + + assert_equal 1, david.reload.projects.size + assert_equal 1, david.projects.reload.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_empty join_records + + assert_equal 0, david.reload.projects.size + assert_equal 0, david.projects.reload.size + end + + def test_destroy_all + david = Developer.find(1) + david.projects.reload + assert_not_empty david.projects + + 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_empty join_records + + assert_empty david.projects + assert_empty david.projects.reload + end + + def test_destroy_associations_destroys_multiple_associations + george = parrots(:george) + assert_not_empty george.pirates + assert_not_empty george.treasures + + 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_empty join_records + assert_empty george.pirates.reload + + join_records = Parrot.connection.select_all("SELECT * FROM parrots_treasures WHERE parrot_id = #{george.id}") + assert_empty join_records + assert_empty george.treasures.reload + 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_predicate project.developers, :loaded? + assert_includes project.developers, 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_not_predicate project.developers, :loaded? + assert_queries(1) do + assert_includes project.developers, developer + end + assert_not_predicate 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_not_predicate project.developers, :loaded? + assert_not 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(&: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_includes project.developers, jamis + assert_includes project.developers, 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_association_with_extend_option + eponine = DeveloperWithExtendOption.create(name: "Eponine") + assert_equal "sns", eponine.projects.category + 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_not_includes david.projects, 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_includes developer.projects, special_project + assert_includes developer.special_projects, special_project + assert_not_includes developer.special_projects, 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_columns_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.reload.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 + assert_equal( + 3, + Developer.includes(projects: :developers).where.not("projects_developers_projects_join.joined_on": nil).to_a.size + ) + end + + def test_join_with_group + 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.includes(projects: :developers).where.not("projects_developers_projects_join.joined_on": nil).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_paid_salary_groups.to_a.size + assert projects(:active_record).well_paid_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.reload + assert_no_queries do + developer.project_ids + developer.project_ids + end + end + + def test_get_ids_for_unloaded_associations_does_not_load_them + developer = developers(:david) + assert_not_predicate developer.projects, :loaded? + assert_equal projects(:active_record, :action_controller).map(&:id).sort, developer.project_ids.sort + assert_not_predicate 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_singular_ids_are_reloaded_after_collection_concat + student = Student.create(name: "Alberto Almagro") + student.lesson_ids + + lesson = Lesson.create(name: "DSI") + student.lessons << lesson + + assert_includes student.lesson_ids, lesson.id + 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.00", "$20.00"].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 + assert_called(Post, :transaction) do + Category.first.posts.transaction do + # nothing + end + 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_habtm_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_habtm_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_includes project.developers, 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 + + def test_habtm_with_reflection_using_class_name_and_fixtures + assert_not_nil Developer._reflections["shared_computers"] + # Checking the fixture for named association is important here, because it's the only way + # we've been able to reproduce this bug + assert_not_nil File.read(File.expand_path("../../fixtures/developers.yml", __dir__)).index("shared_computers") + assert_equal developers(:david).shared_computers.first, computers(:laptop) + end + + def test_with_symbol_class_name + assert_nothing_raised do + developer = DeveloperWithSymbolClassName.new + developer.projects + end + end + + def test_alternate_database + professor = Professor.create(name: "Plum") + course = Course.create(name: "Forensics") + assert_equal 0, professor.courses.count + assert_nothing_raised do + professor.courses << course + end + assert_equal 1, professor.courses.count + end + + def test_habtm_scope_can_unscope + project = ProjectUnscopingDavidDefaultScope.new + project.save! + + developer = LazyBlockDeveloperCalledDavid.new(name: "Not David") + developer.save! + project.developers << developer + + projects = ProjectUnscopingDavidDefaultScope.includes(:developers).where(id: project.id) + assert_equal 1, projects.first.developers.size + end + + def test_preloaded_associations_size + assert_equal Project.first.salaried_developers.size, + Project.preload(:salaried_developers).first.salaried_developers.size + + assert_equal Project.includes(:salaried_developers).references(:salaried_developers).first.salaried_developers.size, + Project.preload(:salaried_developers).first.salaried_developers.size + + # Nested HATBM + first_project = Developer.first.projects.first + preloaded_first_project = + Developer.preload(projects: :salaried_developers). + first. + projects. + detect { |p| p.id == first_project.id } + + assert preloaded_first_project.salaried_developers.loaded?, true + assert_equal first_project.salaried_developers.size, preloaded_first_project.salaried_developers.size + end + + def test_has_and_belongs_to_many_is_useable_with_belongs_to_required_by_default + assert_difference "Project.first.developers_required_by_default.size", 1 do + Project.first.developers_required_by_default.create!(name: "Sean", salary: 50000) + end + end + + def test_association_name_is_the_same_as_join_table_name + user = User.create! + assert_nothing_raised { user.jobs_pool.clear } + end + + def test_has_and_belongs_to_many_while_partial_writes_false + original_partial_writes = ActiveRecord::Base.partial_writes + ActiveRecord::Base.partial_writes = false + developer = Developer.new(name: "Mehmet Emin İNAÇ") + developer.projects << Project.new(name: "Bounty") + + assert developer.save + ensure + ActiveRecord::Base.partial_writes = original_partial_writes + end + + def test_has_and_belongs_to_many_with_belongs_to + sink = Sink.create! kitchen: Kitchen.new, sources: [Source.new] + assert_equal 1, sink.sources.count + 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..5921193374 --- /dev/null +++ b/activerecord/test/cases/associations/has_many_associations_test.rb @@ -0,0 +1,2932 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/computer" +require "models/project" +require "models/company" +require "models/contract" +require "models/topic" +require "models/reply" +require "models/category" +require "models/image" +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/college" +require "models/student" +require "models/pirate" +require "models/ship" +require "models/ship_part" +require "models/treasure" +require "models/parrot" +require "models/tyre" +require "models/subscriber" +require "models/subscription" +require "models/zine" +require "models/interest" + +class HasManyAssociationsTestForReorderWithJoinDependency < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :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 HasManyAssociationsTestPrimaryKeys < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :essays, :subscribers, :subscriptions, :people + + def test_custom_primary_key_on_new_record_should_fetch_with_query + subscriber = Subscriber.new(nick: "webster132") + assert_not_predicate subscriber.subscriptions, :loaded? + + assert_queries 1 do + assert_equal 2, subscriber.subscriptions.size + end + + assert_equal Subscription.where(subscriber_id: "webster132"), subscriber.subscriptions + end + + def test_association_primary_key_on_new_record_should_fetch_with_query + author = Author.new(name: "David") + assert_not_predicate author.essays, :loaded? + + assert_queries 1 do + assert_equal 1, author.essays.size + end + + assert_equal Essay.where(writer_id: "David"), author.essays + end + + def test_has_many_custom_primary_key + david = authors(:david) + assert_equal Essay.where(writer_id: "David"), david.essays + end + + def test_ids_on_unloaded_association_with_custom_primary_key + david = people(:david) + assert_equal Essay.where(writer_id: "David").pluck(:id), david.essay_ids + end + + def test_ids_on_loaded_association_with_custom_primary_key + david = people(:david) + david.essays.load + assert_equal Essay.where(writer_id: "David").pluck(:id), david.essay_ids + 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_not_predicate author.essays, :loaded? + + assert_queries 0 do + assert_equal 0, author.essays.size + end + end +end + +class HasManyAssociationsTest < ActiveRecord::TestCase + fixtures :accounts, :categories, :companies, :developers, :projects, + :developers_projects, :topics, :authors, :author_addresses, :comments, + :posts, :readers, :taggings, :cars, :tags, + :categorizations, :zines, :interests + + 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, anonymous_class: dev + } + has_many :developer_projects, anonymous_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_default_scope_on_relations_is_not_cached + counter = 0 + posts = Class.new(ActiveRecord::Base) { + self.table_name = "posts" + self.inheritance_column = "not_there" + post = self + + comments = Class.new(ActiveRecord::Base) { + self.table_name = "comments" + self.inheritance_column = "not_there" + belongs_to :post, anonymous_class: post + default_scope -> { + counter += 1 + where("id = :inc", inc: counter) + } + } + has_many :comments, anonymous_class: comments, foreign_key: "post_id" + } + assert_equal 0, counter + post = posts.first + assert_equal 0, counter + sql = capture_sql { post.comments.to_a } + post.comments.reset + assert_not_equal sql, capture_sql { post.comments.to_a } + 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_add_record_to_collection_should_change_its_updated_at + ship = Ship.create(name: "dauntless") + part = ShipPart.create(name: "cockpit") + updated_at = part.updated_at + + travel(1.second) do + ship.parts << part + end + + assert_equal part.ship, ship + assert_not_equal part.updated_at, updated_at + end + + def test_clear_collection_should_not_change_updated_at + # GH#17161: .clear calls delete_all (and returns the association), + # which is intended to not touch associated objects's updated_at field + ship = Ship.create(name: "dauntless") + part = ShipPart.create(name: "cockpit", ship_id: ship.id) + + ship.parts.clear + part.reload + + assert_nil part.ship + assert_not_predicate part, :updated_at_changed? + 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 + end + + def test_build_and_create_from_association_should_respect_passed_attributes_over_default_scope + car = Car.create(name: "honda") + + bulb = car.bulbs.build(name: "exotic") + assert_equal "exotic", bulb.name + + bulb = car.bulbs.create(name: "exotic") + assert_equal "exotic", bulb.name + + bulb = car.awesome_bulbs.build(frickinawesome: false) + assert_equal false, bulb.frickinawesome + + bulb = car.awesome_bulbs.create(frickinawesome: false) + assert_equal false, bulb.frickinawesome + 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_build_from_association_sets_inverse_instance + car = Car.new(name: "honda") + + bulb = car.bulbs.build + assert_equal car, bulb.car + end + + def test_do_not_call_callbacks_for_delete_all + car = Car.create(name: "honda") + car.funky_bulbs.create! + assert_equal 1, car.funky_bulbs.count + assert_equal 1, car.reload.funky_bulbs.delete_all + assert_equal 0, car.funky_bulbs.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_delete_all_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + posts.create!(title: "test", body: "body") + posts.delete_all + assert_nil posts.first + 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 + + class SpecialAuthor < ActiveRecord::Base + self.table_name = "authors" + has_many :books, class_name: "SpecialBook", foreign_key: :author_id + end + + class SpecialBook < ActiveRecord::Base + self.table_name = "books" + + belongs_to :author + enum read_status: { unread: 0, reading: 2, read: 3, forgotten: nil } + end + + def test_association_enum_works_properly + author = SpecialAuthor.create!(name: "Test") + book = SpecialBook.create!(read_status: "reading") + author.books << book + + assert_equal "reading", book.read_status + assert_not_equal 0, SpecialAuthor.joins(:books).where(books: { read_status: "reading" }).count + 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) + scope = car.foo_bulbs.where_values_hash + + bulb = car.foo_bulbs.build + assert_not_equal scope, bulb.scope_after_initialize.where_values_hash + + bulb = car.foo_bulbs.create + assert_not_equal scope, bulb.scope_after_initialize.where_values_hash + + bulb = car.foo_bulbs.create! + assert_not_equal scope, bulb.scope_after_initialize.where_values_hash + 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() + end + + assert_no_queries do + bulbs.second() + end + + assert_no_queries do + bulbs.third() + end + + assert_no_queries do + bulbs.fourth() + end + + assert_no_queries do + bulbs.fifth() + end + + assert_no_queries do + bulbs.forty_two() + end + + assert_no_queries do + bulbs.third_to_last() + end + + assert_no_queries do + bulbs.second_to_last() + end + + assert_no_queries do + bulbs.last() + end + end + + def test_finder_method_with_dirty_target + company = companies(:first_firm) + new_clients = [] + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + assert_no_queries do + new_clients << company.clients_of_firm.build(name: "Another Client") + new_clients << company.clients_of_firm.build(name: "Another Client II") + new_clients << company.clients_of_firm.build(name: "Another Client III") + end + + assert_not_predicate company.clients_of_firm, :loaded? + assert_queries(1) do + assert_same new_clients[0], company.clients_of_firm.third + assert_same new_clients[1], company.clients_of_firm.fourth + assert_same new_clients[2], company.clients_of_firm.fifth + assert_same new_clients[0], company.clients_of_firm.third_to_last + assert_same new_clients[1], company.clients_of_firm.second_to_last + assert_same new_clients[2], company.clients_of_firm.last + end + end + + def test_finder_bang_method_with_dirty_target + company = companies(:first_firm) + new_clients = [] + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + assert_no_queries do + new_clients << company.clients_of_firm.build(name: "Another Client") + new_clients << company.clients_of_firm.build(name: "Another Client II") + new_clients << company.clients_of_firm.build(name: "Another Client III") + end + + assert_not_predicate company.clients_of_firm, :loaded? + assert_queries(1) do + assert_same new_clients[0], company.clients_of_firm.third! + assert_same new_clients[1], company.clients_of_firm.fourth! + assert_same new_clients[2], company.clients_of_firm.fifth! + assert_same new_clients[0], company.clients_of_firm.third_to_last! + assert_same new_clients[1], company.clients_of_firm.second_to_last! + assert_same new_clients[2], company.clients_of_firm.last! + end + end + + def test_create_resets_cached_counters + Reader.delete_all + + 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 test_update_all_respects_association_scope + person = Person.new + person.first_name = "Naruto" + person.references << Reference.new + person.save! + assert_equal 1, person.references.update_all(favourite: true) + end + + def test_exists_respects_association_scope + person = Person.new + person.first_name = "Sasuke" + person.references << Reference.new + person.save! + assert_predicate person.references, :exists? + end + + def test_counting_with_counter_sql + assert_equal 3, Firm.first.clients.count + end + + def test_counting + assert_equal 3, Firm.first.plain_clients.count + end + + def test_counting_with_single_hash + assert_equal 1, Firm.first.plain_clients.where(name: "Microsoft").count + end + + def test_counting_with_column_name_and_hash + assert_equal 3, Firm.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.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_taking + posts(:other_by_bob).destroy + assert_equal posts(:misc_by_bob), authors(:bob).posts.take + assert_equal posts(:misc_by_bob), authors(:bob).posts.take! + authors(:bob).posts.to_a + assert_equal posts(:misc_by_bob), authors(:bob).posts.take + assert_equal posts(:misc_by_bob), authors(:bob).posts.take! + end + + def test_taking_not_found + authors(:bob).posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { authors(:bob).posts.take! } + authors(:bob).posts.to_a + assert_raise(ActiveRecord::RecordNotFound) { authors(:bob).posts.take! } + end + + def test_taking_with_a_number + klass = Class.new(Author) do + has_many :posts, -> { order(:id) } + + def self.name + "Author" + end + end + + # taking from unloaded Relation + bob = klass.find(authors(:bob).id) + new_post = bob.posts.build + assert_not_predicate bob.posts, :loaded? + assert_equal [posts(:misc_by_bob)], bob.posts.take(1) + assert_equal [posts(:misc_by_bob), posts(:other_by_bob)], bob.posts.take(2) + assert_equal [posts(:misc_by_bob), posts(:other_by_bob), new_post], bob.posts.take(3) + + # taking from loaded Relation + bob.posts.load + assert_predicate bob.posts, :loaded? + assert_equal [posts(:misc_by_bob)], bob.posts.take(1) + assert_equal [posts(:misc_by_bob), posts(:other_by_bob)], bob.posts.take(2) + assert_equal [posts(:misc_by_bob), posts(:other_by_bob), new_post], bob.posts.take(3) + end + + def test_taking_with_inverse_of + interests(:woodsmanship).destroy + interests(:survival).destroy + + zine = zines(:going_out) + interest = zine.interests.take + assert_equal interests(:hunting), interest + assert_same zine, interest.zine + 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.first.clients.first.name + end + + def test_finding_with_different_class_name_and_order + assert_equal "Apex", Firm.first.clients_sorted_desc.first.name + end + + def test_finding_with_foreign_key + assert_equal "Microsoft", Firm.first.clients_of_firm.first.name + end + + def test_finding_with_condition + assert_equal "Microsoft", Firm.first.clients_like_ms.first.name + end + + def test_finding_with_condition_hash + assert_equal "Microsoft", Firm.first.clients_like_ms_with_hash_conditions.first.name + end + + def test_finding_using_primary_key + assert_equal "Summit", Firm.first.clients_using_primary_key.first.name + end + + def test_update_all_on_association_accessed_before_save + firm = Firm.new(name: "Firm") + firm.clients << Client.first + firm.save! + assert_equal firm.clients.count, firm.clients.update_all(description: "Great!") + end + + def test_update_all_on_association_accessed_before_save_with_explicit_foreign_key + firm = Firm.new(name: "Firm", id: 100) + firm.clients << Client.first + firm.save! + assert_equal firm.clients.count, firm.clients.update_all(description: "Great!") + 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.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_one_message_on_primary_key + firm = Firm.first + + e = assert_raises(ActiveRecord::RecordNotFound) do + firm.clients.find(0) + end + assert_equal 0, e.id + assert_equal "id", e.primary_key + assert_equal "Client", e.model + assert_match (/\ACouldn't find Client with 'id'=0/), e.message + end + + def test_find_ids_and_inverse_of + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.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_not_predicate firm.clients, :loaded? + + assert_queries(4) do + firm.clients.find_each(batch_size: 1) { |c| assert_equal firm.id, c.firm_id } + end + + assert_not_predicate 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_not_predicate firm.clients, :loaded? + end + + def test_find_in_batches + firm = companies(:first_firm) + + assert_not_predicate 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_not_predicate firm.clients, :loaded? + end + + def test_find_all_sanitized + firm = Firm.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.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.first + client2 = Client.find(2) + assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = ?", "Client").first + assert_equal client2, firm.clients.where("#{QUOTED_TYPE} = :type", type: "Client").first + end + + def test_find_first_after_reset_scope + firm = Firm.first + collection = firm.clients + + original_object = collection.first + assert_same original_object, collection.first, "Expected second call to #first to cache the same object" + + # It should return a different object, since the association has been reloaded + assert_not_same original_object, firm.clients.first, "Expected #first to return a new object" + end + + def test_find_first_after_reset + firm = Firm.first + collection = firm.clients + + original_object = collection.first + assert_same original_object, collection.first, "Expected second call to #first to cache the same object" + collection.reset + + # It should return a different object, since the association has been reloaded + assert_not_same original_object, collection.first, "Expected #first after #reset to return a new object" + end + + def test_find_first_after_reload + firm = Firm.first + collection = firm.clients + + original_object = collection.first + assert_same original_object, collection.first, "Expected second call to #first to cache the same object" + collection.reload + + # It should return a different object, since the association has been reloaded + assert_not_same original_object, collection.first, "Expected #first after #reload to return a new object" + end + + def test_reload_with_query_cache + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + # Populate the cache with a query + firm = Firm.first + # Populate the cache with a second query + firm.clients.load + + assert_equal 2, connection.query_cache.size + + # Clear the cache and fetch the clients again, populating the cache with a query + assert_queries(1) { firm.clients.reload } + # This query is cached, so it shouldn't make a real SQL query + assert_queries(0) { firm.clients.load } + + assert_equal 1, connection.query_cache.size + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + + def test_reloading_unloaded_associations_with_query_cache + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + firm = Firm.create!(name: "firm name") + client = firm.clients.create!(name: "client name") + firm.clients.to_a # add request to cache + + connection.uncached do + client.update!(name: "new client name") + end + + firm = Firm.find(firm.id) + + assert_equal [client.name], firm.clients.reload.map(&:name) + ensure + ActiveRecord::Base.connection.disable_query_cache! + 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 2, 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_with_block_and_dirty_target + assert_equal 2, posts(:welcome).comments.select { true }.size + posts(:welcome).comments.build + assert_equal 3, posts(:welcome).comments.select { true }.size + 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 + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.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 + error = assert_raise(ActiveRecord::RecordNotSaved) do + firm = Firm.new + firm.plain_clients.create! name: "Whoever" + end + + assert_equal "You cannot call create unless the parent is saved", error.message + end + + def test_regular_create_on_has_many_when_parent_is_new_raises + error = assert_raise(ActiveRecord::RecordNotSaved) do + firm = Firm.new + firm.plain_clients.create name: "Whoever" + end + + assert_equal "You cannot call create unless the parent is saved", error.message + end + + def test_create_with_bang_on_has_many_raises_when_record_not_saved + assert_raise(ActiveRecord::RecordInvalid) do + firm = Firm.first + firm.plain_clients.create! + end + end + + def test_create_with_bang_on_habtm_when_parent_is_new_raises + error = assert_raise(ActiveRecord::RecordNotSaved) do + Developer.new("name" => "Aredridel").projects.create! + end + + assert_equal "You cannot call create unless the parent is saved", error.message + 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 + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.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_not_includes companies(:first_firm).clients_of_firm.reload, good + end + + def test_transactions_when_adding_to_new_record + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + firm = Firm.new + assert_no_queries do + 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) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.new("name" => "Another Client") } + assert_not_predicate company.clients_of_firm, :loaded? + + assert_equal "Another Client", new_client.name + assert_not_predicate new_client, :persisted? + assert_equal new_client, company.clients_of_firm.last + end + + def test_build + company = companies(:first_firm) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") } + assert_not_predicate company.clients_of_firm, :loaded? + + assert_equal "Another Client", new_client.name + assert_not_predicate 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 + assert_equal 4, company.clients_of_firm.uniq.size + end + + def test_collection_not_empty_after_building + company = companies(:first_firm) + assert_empty company.contracts + company.contracts.build + assert_not_empty company.contracts + end + + def test_collection_size_with_dirty_target + post = posts(:thinking) + assert_equal [], post.reader_ids + assert_equal 0, post.readers.size + post.readers.reset + post.readers.build + assert_equal [nil], post.reader_ids + assert_equal 1, post.readers.size + end + + def test_collection_empty_with_dirty_target + post = posts(:thinking) + assert_equal [], post.reader_ids + assert_empty post.readers + post.readers.reset + post.readers.build + assert_equal [nil], post.reader_ids + assert_not_empty post.readers + 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) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + 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_not_predicate companies(:first_firm).clients_of_firm, :loaded? + end + + def test_build_without_loading_association + first_topic = topics(:first) + + assert_equal 1, first_topic.replies.length + + # Load schema information so we don't query below if running just this test. + Reply.define_attribute_methods + + 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) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build { |client| client.name = "Another Client" } } + assert_not_predicate company.clients_of_firm, :loaded? + + assert_equal "Another Client", new_client.name + assert_not_predicate new_client, :persisted? + assert_equal new_client, company.clients_of_firm.last + end + + def test_build_many_via_block + company = companies(:first_firm) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + 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) + + 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 + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + new_client = companies(:first_firm).clients_of_firm.create("name" => "Another Client") + assert_predicate new_client, :persisted? + assert_equal new_client, companies(:first_firm).clients_of_firm.last + assert_equal new_client, companies(:first_firm).clients_of_firm.reload.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.reload.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_not_predicate companies(:first_firm).clients_of_firm, :loaded? + end + + def test_deleting + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.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_has_many_without_counter_cache_option + # Ship has a conventionally named `treasures_count` column, but the counter_cache + # option is not given on the association. + ship = Ship.create(name: "Countless", treasures_count: 10) + + assert_not_predicate Ship.reflect_on_association(:treasures), :has_cached_counter? + + # Count should come from sql count() of treasures rather than treasures_count attribute + assert_equal ship.treasures.size, 0 + + assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do + ship.treasures.create(name: "Gold") + end + + assert_no_difference lambda { ship.reload.treasures_count }, "treasures_count should not be changed" do + ship.treasures.destroy_all + end + 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_counter_cache_updates_in_memory_after_update_with_inverse_of_disabled + topic = Topic.create!(title: "Zoom-zoom-zoom") + + assert_equal 0, topic.replies_count + + reply1 = Reply.create!(title: "re: zoom", content: "speedy quick!") + reply2 = Reply.create!(title: "re: zoom 2", content: "OMG lol!") + + assert_queries(4) do + topic.replies << [reply1, reply2] + end + + assert_equal 2, topic.replies_count + assert_equal 2, topic.reload.replies_count + end + + def test_counter_cache_updates_in_memory_after_update_with_inverse_of_enabled + category = Category.create!(name: "Counter Cache") + + assert_nil category.categorizations_count + + categorization1 = Categorization.create! + categorization2 = Categorization.create! + + assert_queries(4) do + category.categorizations << [categorization1, categorization2] + end + + assert_equal 2, category.categorizations_count + assert_equal 2, category.reload.categorizations_count + 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_no_queries do + assert_not_empty post.comments + 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_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(parent_id: nil) + assert_equal original_count - 1, topic.reload.replies_count + + first_reply.update(parent_id: topic.id) + assert_equal original_count, topic.reload.replies_count + end + + def test_calling_update_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(parent_id: topic2.id) + assert_equal original_count1 - 1, topic1.reload.replies_count + assert_equal original_count2 + 1, topic2.reload.replies_count + + reply2.update(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 + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.size + end + + def test_delete_all + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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 + assert_equal clients.count, 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 + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.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.reload + end + + def test_transaction_when_deleting_new_record + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + firm = Firm.new + assert_no_queries do + 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.reload.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.reload.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 + count = firm.dependent_clients_of_firm.count + assert_equal count, firm.dependent_clients_of_firm.delete_all(:delete_all) + assert_nil Client.find_by_id(client_id) + end + + def test_delete_all_with_option_nullify + firm = companies(:first_firm) + client_id = firm.dependent_clients_of_firm.first.id + count = firm.dependent_clients_of_firm.count + assert_equal firm, Client.find(client_id).firm + assert_equal count, firm.dependent_clients_of_firm.delete_all(:nullify) + assert_nil Client.find(client_id).firm + 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.reload.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_hash_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.reload.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_predicate 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.reload.size + end + + def test_deleting_a_item_which_is_not_in_the_collection + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.size + assert_equal 2, summit.client_of + end + + def test_deleting_by_integer_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_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.size + end + + def test_destroying_by_integer_id + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.size + end + + def test_destroying_by_string_id + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.size + end + + def test_destroying_a_collection + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + 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.reload.size + end + + def test_destroy_all + force_signal37_to_load_all_clients_of_firm + + assert_predicate companies(:first_firm).clients_of_firm, :loaded? + + clients = companies(:first_firm).clients_of_firm.to_a + assert_not 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?(&: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.reload.empty?, "37signals has no clients after destroy all and refresh" + end + + def test_destroy_all_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + posts.create!(title: "test", body: "body") + posts.destroy_all + assert_nil posts.first + end + + def test_destroy_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + post = posts.create!(title: "test", body: "body") + posts.destroy(post) + assert_nil posts.first + end + + def test_delete_on_association_clears_scope + author = Author.create!(name: "Gannon") + posts = author.posts + post = posts.create!(title: "test", body: "body") + posts.delete(post) + assert_nil posts.first + end + + def test_dependence + firm = companies(:first_firm) + assert_equal 3, firm.clients.size + firm.destroy + assert_empty Client.all.merge!(where: "firm_id=#{firm.id}").to_a + 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 + firm = Firm.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 + + 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_not_empty firm.companies + 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_not_empty firm.companies + + firm.destroy + + assert_not_empty firm.errors + + 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_restrict_with_error_with_locale + I18n.backend = I18n::Backend::Simple.new + I18n.backend.store_translations "en", activerecord: { attributes: { restricted_with_error_firm: { companies: "client companies" } } } + firm = RestrictedWithErrorFirm.create!(name: "restrict") + firm.companies.create(name: "child") + + assert_not_empty firm.companies + + firm.destroy + + assert_not_empty firm.errors + + assert_equal "Cannot delete record because dependent client companies exist", firm.errors[:base].first + assert RestrictedWithErrorFirm.exists?(name: "restrict") + assert firm.companies.exists?(name: "child") + ensure + I18n.backend.reload! + 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.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.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_not_predicate account, :valid? + assert_not_empty orig_accounts + error = assert_raise ActiveRecord::RecordNotSaved do + firm.accounts = [account] + end + + assert_equal orig_accounts, firm.accounts + assert_equal "Failed to replace accounts because one or more of the " \ + "new records could not be saved.", error.message + end + + def test_replace_with_same_content + firm = Firm.first + firm.clients = [] + firm.save + + assert_no_queries do + firm.clients = [] + end + + assert_equal [], firm.send("clients=", []) + 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.reload + end + + def test_transactions_when_replacing_on_new_record + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + firm = Firm.new + assert_no_queries do + 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.reload + assert_no_queries 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_not_predicate company.clients, :loaded? + assert_equal [companies(:first_client).id, companies(:second_client).id, companies(:another_first_firm_client).id], company.client_ids + assert_not_predicate company.clients, :loaded? + end + + def test_counter_cache_on_unloaded_association + car = Car.create(name: "My AppliCar") + assert_equal car.engines.size, 0 + 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 + # Load schema information so we don't query below if running just this test. + companies(:first_client).contract_ids + + company = Company.new + assert_no_queries 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.reload.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_associations_order_should_be_priority_over_throughs_order + david = authors(:david) + expected = [12, 10, 9, 8, 7, 6, 5, 3, 2, 1] + assert_equal expected, david.comments_desc.map(&:id) + assert_equal expected, Author.includes(:comments_desc).find(david.id).comments_desc.map(&:id) + 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.sort_by(&:id), authors(:david).hello_posts_with_hash_conditions.sort_by(&:id) + assert_equal authors(:david).hello_post_comments.sort_by(&:id), authors(:david).hello_post_comments_with_hash_conditions.sort_by(&:id) + 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_predicate 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_not_predicate firm.clients, :loaded? + assert_queries(1) do + assert_equal true, firm.clients.include?(client) + end + assert_not_predicate 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_not_predicate 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_not_predicate 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_predicate 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_not_predicate firm.clients, :loaded? + + assert_queries 1 do + firm.clients.first + firm.clients.second + firm.clients.last + end + + assert_predicate 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_not_predicate firm.clients, :loaded? + + assert_queries 3 do + firm.clients.first + firm.clients.second + firm.clients.last + end + + assert_not_predicate 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_calling_first_or_last_with_integer_on_association_should_not_load_association + firm = companies(:first_firm) + firm.clients.create(name: "Foo") + assert_not_predicate firm.clients, :loaded? + + assert_queries 2 do + firm.clients.first(2) + firm.clients.last(2) + end + + assert_not_predicate 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_not_predicate firm.clients, :loaded? + end + + def test_calling_many_on_loaded_association_should_not_use_query + firm = companies(:first_firm) + firm.clients.load # 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 + assert_not_called(firm.clients, :size) do + firm.clients.many? { true } + end + end + assert_predicate firm.clients, :loaded? + end + + def test_calling_many_should_return_false_if_none_or_one + firm = companies(:another_firm) + assert_not_predicate firm.clients_like_ms, :many? + assert_equal 0, firm.clients_like_ms.size + + firm = companies(:first_firm) + assert_not_predicate 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_predicate firm.clients, :many? + assert_equal 3, firm.clients.size + end + + def test_calling_none_should_count_instead_of_loading_association + firm = companies(:first_firm) + assert_queries(1) do + firm.clients.none? # use count query + end + assert_not_predicate firm.clients, :loaded? + end + + def test_calling_none_on_loaded_association_should_not_use_query + firm = companies(:first_firm) + firm.clients.load # force load + assert_no_queries { assert_not firm.clients.none? } + end + + def test_calling_none_should_defer_to_collection_if_using_a_block + firm = companies(:first_firm) + assert_queries(1) do + assert_not_called(firm.clients, :size) do + firm.clients.none? { true } + end + end + assert_predicate firm.clients, :loaded? + end + + def test_calling_none_should_return_true_if_none + firm = companies(:another_firm) + assert_predicate firm.clients_like_ms, :none? + assert_equal 0, firm.clients_like_ms.size + end + + def test_calling_none_should_return_false_if_any + firm = companies(:first_firm) + assert_not_predicate firm.limited_clients, :none? + assert_equal 1, firm.limited_clients.size + end + + def test_calling_one_should_count_instead_of_loading_association + firm = companies(:first_firm) + assert_queries(1) do + firm.clients.one? # use count query + end + assert_not_predicate firm.clients, :loaded? + end + + def test_calling_one_on_loaded_association_should_not_use_query + firm = companies(:first_firm) + firm.clients.load # force load + assert_no_queries { assert_not firm.clients.one? } + end + + def test_calling_one_should_defer_to_collection_if_using_a_block + firm = companies(:first_firm) + assert_queries(1) do + assert_not_called(firm.clients, :size) do + firm.clients.one? { true } + end + end + assert_predicate firm.clients, :loaded? + end + + def test_calling_one_should_return_false_if_zero + firm = companies(:another_firm) + assert_not_predicate firm.clients_like_ms, :one? + assert_equal 0, firm.clients_like_ms.size + end + + def test_calling_one_should_return_true_if_one + firm = companies(:first_firm) + assert_predicate firm.limited_clients, :one? + assert_equal 1, firm.limited_clients.size + end + + def test_calling_one_should_return_false_if_more_than_one + firm = companies(:first_firm) + assert_not_predicate firm.clients, :one? + 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 + assert_called(Comment, :transaction) do + Post.first.comments.transaction do + # nothing + end + 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_creating_using_primary_key + firm = Firm.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 + assert_not_called_on_instance_of( + ActiveRecord::Reflection::AssociationReflection, + :class_name, + ) do + class_eval(<<-EOF, __FILE__, __LINE__ + 1) + class DeleteAllModel < ActiveRecord::Base + has_many :nonentities, :dependent => :delete_all + end + EOF + end + end + + def test_defining_has_many_association_with_nullify_dependency_lazily_evaluates_target_class + assert_not_called_on_instance_of( + ActiveRecord::Reflection::AssociationReflection, + :class_name, + ) do + class_eval(<<-EOF, __FILE__, __LINE__ + 1) + class NullifyModel < ActiveRecord::Base + has_many :nonentities, :dependent => :nullify + end + EOF + end + 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_with_polymorphic_has_many_with_custom_columns_name + post = Post.create! title: "foo", body: "bar" + image = Image.create! + + post.images << image + + assert_equal [image], post.images + 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_build_from_polymorphic_association_sets_inverse_instance + post = Post.new + tagging = post.taggings.build + + assert_equal post, tagging.taggable + 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 + assert_equal [accounts(:signals37)], firm.accounts.available + end + + def test_association_with_or_doesnt_set_inverse_instance_key + firm = companies(:first_firm) + accounts = firm.accounts.or(Account.where(firm_id: nil)).order(:id) + assert_equal [firm.id, nil], accounts.map(&:firm_id) + end + + def test_association_with_rewhere_doesnt_set_inverse_instance_key + firm = companies(:first_firm) + accounts = firm.accounts.rewhere(firm_id: [firm.id, nil]).order(:id) + assert_equal [firm.id, nil], accounts.map(&:firm_id) + 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_not_predicate 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_not_predicate 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 "hullo", post.comments_with_extend_2.greeting + end + + test "extend option affects per association" do + post = posts(:welcome) + assert_equal "lifo", post.comments_with_extend.author + assert_equal "lifo", post.comments_with_extend_2.author + assert_equal "hello", post.comments_with_extend.greeting + assert_equal "hullo", 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 "can unscope and where the default scope of the associated model" do + Car.has_many :other_bulbs, -> { unscope(where: [:name]).where(name: "other") }, class_name: "Bulb" + car = Car.create! + bulb1 = Bulb.create! name: "defaulty", car: car + bulb2 = Bulb.create! name: "other", car: car + + assert_equal [bulb1], car.bulbs + assert_equal [bulb2], car.other_bulbs + end + + test "can rewhere the default scope of the associated model" do + Car.has_many :old_bulbs, -> { rewhere(name: "old") }, class_name: "Bulb" + car = Car.create! + bulb1 = Bulb.create! name: "defaulty", car: car + bulb2 = Bulb.create! name: "old", car: car + + assert_equal [bulb1], car.bulbs + assert_equal [bulb2], car.old_bulbs + end + + test "unscopes the default scope of associated model when used with include" do + car = Car.create! + bulb = Bulb.create! name: "other", car: car + + assert_equal [bulb], Car.find(car.id).all_bulbs + assert_equal [bulb], Car.includes(:all_bulbs).find(car.id).all_bulbs + assert_equal [bulb], Car.eager_load(:all_bulbs).find(car.id).all_bulbs + end + + test "raises RecordNotDestroyed when replaced child can't be destroyed" do + car = Car.create! + original_child = FailedBulb.create!(car: car) + + error = assert_raise(ActiveRecord::RecordNotDestroyed) do + car.failed_bulbs = [FailedBulb.create!] + end + + assert_equal [original_child], car.reload.failed_bulbs + assert_equal "Failed to destroy the record", error.message + 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_predicate 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 + + test "associations autosaves when object is already persisted" do + bulb = Bulb.create! + tyre = Tyre.create! + + car = Car.create! do |c| + c.bulbs << bulb + c.tyres << tyre + end + + assert_equal 1, car.bulbs.count + assert_equal 1, car.tyres.count + end + + test "associations replace in memory when records have the same id" do + bulb = Bulb.create! + car = Car.create!(bulbs: [bulb]) + + new_bulb = Bulb.find(bulb.id) + new_bulb.name = "foo" + car.bulbs = [new_bulb] + + assert_equal "foo", car.bulbs.first.name + end + + test "in memory replacement executes no queries" do + bulb = Bulb.create! + car = Car.create!(bulbs: [bulb]) + + new_bulb = Bulb.find(bulb.id) + + assert_no_queries do + car.bulbs = [new_bulb] + end + end + + test "in memory replacements do not execute callbacks" do + raise_after_add = false + klass = Class.new(ActiveRecord::Base) do + self.table_name = :cars + has_many :bulbs, after_add: proc { raise if raise_after_add } + + def self.name + "Car" + end + end + bulb = Bulb.create! + car = klass.create!(bulbs: [bulb]) + + new_bulb = Bulb.find(bulb.id) + raise_after_add = true + + assert_nothing_raised do + car.bulbs = [new_bulb] + end + end + + test "in memory replacements sets inverse instance" do + bulb = Bulb.create! + car = Car.create!(bulbs: [bulb]) + + new_bulb = Bulb.find(bulb.id) + car.bulbs = [new_bulb] + + assert_same car, new_bulb.car + end + + test "reattach to new objects replaces inverse association and foreign key" do + bulb = Bulb.create!(car: Car.create!) + assert bulb.car_id + car = Car.new + car.bulbs << bulb + assert_equal car, bulb.car + assert_nil bulb.car_id + end + + test "in memory replacement maintains order" do + first_bulb = Bulb.create! + second_bulb = Bulb.create! + car = Car.create!(bulbs: [first_bulb, second_bulb]) + + same_bulb = Bulb.find(first_bulb.id) + car.bulbs = [second_bulb, same_bulb] + + assert_equal [first_bulb, second_bulb], car.bulbs + end + + test "association size calculation works with default scoped selects when not previously fetched" do + firm = Firm.create!(name: "Firm") + 5.times { firm.developers_with_select << Developer.create!(name: "Developer") } + + same_firm = Firm.find(firm.id) + assert_equal 5, same_firm.developers_with_select.size + end + + test "prevent double insertion of new object when the parent association loaded in the after save callback" do + reset_callbacks(:save, Bulb) do + Bulb.after_save { |record| record.car.bulbs.load } + + car = Car.create! + car.bulbs << Bulb.new + + assert_equal 1, car.bulbs.size + end + end + + test "prevent double firing the before save callback of new object when the parent association saved in the callback" do + reset_callbacks(:save, Bulb) do + count = 0 + Bulb.before_save { |record| record.car.save && count += 1 } + + car = Car.create! + car.bulbs.create! + + assert_equal 1, count + end + end + + test "calling size on an association that has not been loaded performs a query" do + car = Car.create! + Bulb.create(car_id: car.id) + + car_two = Car.create! + + assert_queries(1) do + assert_equal 1, car.bulbs.size + end + + assert_queries(1) do + assert_equal 0, car_two.bulbs.size + end + end + + test "calling size on an association that has been loaded does not perform query" do + car = Car.create! + Bulb.create(car_id: car.id) + car.bulb_ids + + car_two = Car.create! + car_two.bulb_ids + + assert_no_queries do + assert_equal 1, car.bulbs.size + end + + assert_no_queries do + assert_equal 0, car_two.bulbs.size + end + end + + test "calling empty on an association that has not been loaded performs a query" do + car = Car.create! + Bulb.create(car_id: car.id) + + car_two = Car.create! + + assert_queries(1) do + assert_not_empty car.bulbs + end + + assert_queries(1) do + assert_empty car_two.bulbs + end + end + + test "calling empty on an association that has been loaded does not performs query" do + car = Car.create! + Bulb.create(car_id: car.id) + car.bulb_ids + + car_two = Car.create! + car_two.bulb_ids + + assert_no_queries do + assert_not_empty car.bulbs + end + + assert_no_queries do + assert_empty car_two.bulbs + end + end + + class AuthorWithErrorDestroyingAssociation < ActiveRecord::Base + self.table_name = "authors" + has_many :posts_with_error_destroying, + class_name: "PostWithErrorDestroying", + foreign_key: :author_id, + dependent: :destroy + end + + class PostWithErrorDestroying < ActiveRecord::Base + self.table_name = "posts" + self.inheritance_column = nil + before_destroy -> { throw :abort } + end + + def test_destroy_does_not_raise_when_association_errors_on_destroy + assert_no_difference "AuthorWithErrorDestroyingAssociation.count" do + author = AuthorWithErrorDestroyingAssociation.first + + assert_not author.destroy + end + end + + def test_destroy_with_bang_bubbles_errors_from_associations + error = assert_raises ActiveRecord::RecordNotDestroyed do + AuthorWithErrorDestroyingAssociation.first.destroy! + end + + assert_instance_of PostWithErrorDestroying, error.record + end + + def test_ids_reader_memoization + car = Car.create!(name: "Tofaş") + bulb = Bulb.create!(car: car) + + assert_equal [bulb.id], car.bulb_ids + assert_no_queries { car.bulb_ids } + + bulb2 = car.bulbs.create! + + assert_equal [bulb.id, bulb2.id], car.bulb_ids + assert_no_queries { car.bulb_ids } + end + + def test_loading_association_in_validate_callback_doesnt_affect_persistence + reset_callbacks(:validation, Bulb) do + Bulb.after_validation { |record| record.car.bulbs.load } + + car = Car.create!(name: "Car") + bulb = car.bulbs.create! + + assert_equal [bulb], car.bulbs + end + end + + def test_create_children_could_be_rolled_back_by_after_save + firm = Firm.create!(name: "A New Firm, Inc") + assert_no_difference "Client.count" do + client = firm.clients.create(name: "New Client") do |cli| + cli.rollback_on_save = true + assert_not cli.rollback_on_create_called + end + assert client.rollback_on_create_called + end + end + + private + + def force_signal37_to_load_all_clients_of_firm + companies(:first_firm).clients_of_firm.load_target + end + + def reset_callbacks(kind, klass) + old_callbacks = {} + old_callbacks[klass] = klass.send("_#{kind}_callbacks").dup + klass.subclasses.each do |subclass| + old_callbacks[subclass] = subclass.send("_#{kind}_callbacks").dup + end + yield + ensure + klass.send("_#{kind}_callbacks=", old_callbacks[klass]) + klass.subclasses.each do |subclass| + subclass.send("_#{kind}_callbacks=", old_callbacks[subclass]) + end + 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..bd535357ee --- /dev/null +++ b/activerecord/test/cases/associations/has_many_through_associations_test.rb @@ -0,0 +1,1481 @@ +# frozen_string_literal: true + +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/pet_treasure" +require "models/toy" +require "models/treasure" +require "models/contract" +require "models/company" +require "models/developer" +require "models/computer" +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" +require "models/organization" +require "models/user" +require "models/family" +require "models/family_tree" + +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, :organizations + + # 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_marshal_dump + preloaded = Post.includes(:first_blue_tags).first + assert_equal preloaded, Marshal.load(Marshal.dump(preloaded)) + end + + def test_preload_sti_rhs_class + developers = Developer.includes(:firms).all.to_a + assert_no_queries do + developers.each(&: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 test_preload_multiple_instances_of_the_same_record + club = Club.create!(name: "Aaron cool banana club") + Membership.create! club: club, member: Member.create!(name: "Aaron") + Membership.create! club: club, member: Member.create!(name: "Bob") + + preloaded_clubs = Club.joins(:memberships).preload(:membership).to_a + assert_no_queries { preloaded_clubs.each(&:membership) } + end + + def test_ordered_has_many_through + 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, anonymous_class: book + subscription.belongs_to :subscriber, anonymous_class: subscriber + + book.has_many :subscriptions, anonymous_class: subscription + book.has_many :subscribers, through: :subscriptions, anonymous_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 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_includes person.posts, 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_includes post.people, person + end + + assert_includes post.reload.people.reload, 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 + assert_equal 1, 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 + assert_equal 1, 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 + assert_equal 1, person.reload.jobs_with_dependent_delete_all.delete_all + end + end + end + + def test_delete_all_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + people.create!(first_name: "Jeb") + people.delete_all + assert_nil people.first + 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.reload.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_not_includes post.people.reload, person + end + + def test_associating_new + assert_queries(1) { posts(:thinking) } + new_person = nil # so block binding catches it + + # Load schema information so we don't query below if running just this test. + Person.define_attribute_methods + + assert_no_queries 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_includes posts(:thinking).people, new_person + end + + assert_includes posts(:thinking).reload.people.reload, new_person + end + + def test_associate_new_by_building + assert_queries(1) { posts(:thinking) } + + # Load schema information so we don't query below if running just this test. + Person.define_attribute_methods + + assert_no_queries 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_includes posts(:thinking).people.collect(&:first_name), "Bob" + assert_includes posts(:thinking).people.collect(&:first_name), "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_includes posts(:thinking).reload.people.reload.collect(&:first_name), "Bob" + assert_includes posts(:thinking).reload.people.reload.collect(&:first_name), "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_includes post.people, 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_includes post.single_people, person + end + + def test_build_then_remove_then_save + post = posts(:thinking) + post.people.build(first_name: "Bob") + ted = post.people.build(first_name: "Ted") + post.people.delete(ted) + post.save! + post.reload + + assert_equal ["Bob"], post.people.collect(&:first_name) + 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_empty posts(:welcome).people + end + + assert_empty posts(:welcome).reload.people.reload + 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_empty posts(:welcome).reload.people + assert_empty posts(:welcome).people.reload + 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_empty posts(:welcome).reload.people + assert_empty posts(:welcome).people.reload + end + + def test_destroy_all_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + people.create!(first_name: "Jeb") + people.destroy_all + assert_nil people.first + end + + def test_destroy_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + person = people.create!(first_name: "Jeb") + people.destroy(person) + assert_nil people.first + end + + def test_delete_on_association_clears_scope + post = Post.create!(title: "Rails 6", body: "") + people = post.people + person = people.create!(first_name: "Jeb") + people.delete(person) + assert_nil people.first + 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_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_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_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_update_counter_caches_on_destroy_with_indestructible_through_record + post = posts(:welcome) + tag = post.indestructible_tags.create!(name: "doomed") + post.update_columns(indestructible_tags_count: post.indestructible_tags.count) + + assert_no_difference "post.reload.indestructible_tags_count" do + posts(:welcome).indestructible_tags.destroy(tag) + end + end + + def test_replace_association + assert_queries(4) { posts(:welcome); people(:david); people(:michael); posts(:welcome).people.reload } + + # 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_no_queries do + assert_includes posts(:welcome).people, people(:david) + assert_not_includes posts(:welcome).people, people(:michael) + end + + assert_includes posts(:welcome).reload.people.reload, people(:david) + assert_not_includes posts(:welcome).reload.people.reload, people(:michael) + end + + def test_replace_association_with_duplicates + post = posts(:thinking) + person = people(:david) + + assert_difference "post.people.count", 2 do + post.people = [person] + post.people = [person, person] + end + 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_includes posts(:thinking).people.collect(&:first_name), "Jeb" + end + + assert_includes posts(:thinking).reload.people.reload.collect(&:first_name), "Jeb" + end + + def test_through_record_is_built_when_created_with_where + assert_difference("posts(:thinking).readers.count", 1) do + posts(:thinking).people.where(first_name: "Jeb").create + end + 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 + + error = assert_raises(ActiveRecord::RecordNotSaved) { p.people.create(first_name: "mew") } + assert_equal "You cannot call create unless the parent is saved", error.message + + error = assert_raises(ActiveRecord::RecordNotSaved) { p.people.create!(first_name: "snow") } + assert_equal "You cannot call create unless the parent is saved", error.message + 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.reload } + + assert_queries(1) do + posts(:welcome).people.clear + end + + assert_no_queries do + assert_empty posts(:welcome).people + end + + assert_empty posts(:welcome).reload.people.reload + 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) + + post.people_with_callbacks.build { |person| person.first_name = "Ted" } + assert_equal [ + [:added, :before, "Ted"], + [:added, :after, "Ted"] + ], log.last(2) + + post.people_with_callbacks.create { |person| person.first_name = "Sam" } + assert_equal [ + [:added, :before, "Sam"], + [:added, :after, "Sam"] + ], log.last(2) + 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)) + assert_not_called(ActiveRecord::Associations::Preloader, :new) do + posts(:welcome).misc_tag_ids + end + end + + def test_get_ids_for_loaded_associations + person = people(:michael) + person.posts.reload + assert_no_queries do + person.post_ids + person.post_ids + end + end + + def test_get_ids_for_unloaded_associations_does_not_load_them + person = people(:michael) + assert_not_predicate person.posts, :loaded? + assert_equal [posts(:welcome).id, posts(:authorless).id].sort, person.post_ids.sort + assert_not_predicate person.posts, :loaded? + end + + def test_association_proxy_transaction_method_starts_transaction_in_association_class + assert_called(Tag, :transaction) do + Post.first.tags.transaction do + # nothing + end + 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).sort, people(:susan).agents_of_agents.sort + 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_includes author.named_categories.reload, 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_includes author.named_categories.reload, 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_not Categorization.exists?(author_id: author.id, named_category_name: category.name) + assert_empty author.named_categories.reload + 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_required_type_cast + company = companies(:rails_core) + dev = Developer.first + + company.developer_ids = [dev.id.to_s] + 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.reload + + book.subscriber_ids = [] + assert_equal [], book.subscribers.reload + end + end + + def test_collection_singular_ids_setter_raises_exception_when_invalid_ids_set + company = companies(:rails_core) + ids = [Developer.first.id, -9999] + e = assert_raises(ActiveRecord::RecordNotFound) { company.developer_ids = ids } + msg = "Couldn't find all Developers with 'id': (1, -9999) (found 1 results, but was looking for 2). Couldn't find Developer with id -9999." + assert_equal(msg, e.message) + end + + def test_collection_singular_ids_through_setter_raises_exception_when_invalid_ids_set + author = authors(:david) + ids = [categories(:general).name, "Unknown"] + e = assert_raises(ActiveRecord::RecordNotFound) { author.essay_category_ids = ids } + msg = "Couldn't find all Categories with 'name': (General, Unknown) (found 1 results, but was looking for 2). Couldn't find Category with name Unknown." + assert_equal msg, e.message + 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_includes person.jobs, 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_includes author.comments, comment + end + + def test_through_association_readonly_should_be_false + assert_not_predicate people(:michael).posts.first, :readonly? + assert_not_predicate 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_rewhere + post = TaggedPost.create!(title: "Tagged", body: "Post") + tag = post.tags.create!(name: "Tag") + assert_equal [tag], TaggedPost.preload(:tags).last.tags + assert_equal [tag], TaggedPost.eager_load(:tags).last.tags + 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_distinct + 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_not_predicate proxy, :stale_target? + assert_equal authors(:mary).categorizations.sort_by(&:id), post.author_categorizations.sort_by(&:id) + + post.author_id = authors(:david).id + + assert_predicate 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_includes post.author_addresses, address + post.author_addresses.delete(address) + assert_predicate 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_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_save_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_not 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_preloading_empty_through_with_polymorphic_source_association + owner = Owner.create!(name: "Rainbow Unicat") + pet = Pet.create!(owner: owner) + person = Person.create!(first_name: "Gaga") + treasure = Treasure.create!(looter: person) + non_looted_treasure = Treasure.create!() + PetTreasure.create!(pet: pet, treasure: treasure, rainbow_color: "Ultra violet indigo") + PetTreasure.create!(pet: pet, treasure: non_looted_treasure, rainbow_color: "Ultra violet indigo") + + assert_equal [person], Owner.where(name: "Rainbow Unicat").includes(pets: :persons).first.persons.to_a + 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_includes owner.toys.to_sql, "pets.name desc" + assert_equal ["parrot", "bulbul"], owner.toys.map { |r| r.pet.name } + end + + def test_has_many_through_associations_sum_on_columns + post1 = Post.create(title: "active", body: "sample") + post2 = Post.create(title: "inactive", body: "sample") + + person1 = Person.create(first_name: "aaron", followers_count: 1) + person2 = Person.create(first_name: "schmit", followers_count: 2) + person3 = Person.create(first_name: "bill", followers_count: 3) + person4 = Person.create(first_name: "cal", followers_count: 4) + + Reader.create(post_id: post1.id, person_id: person1.id) + Reader.create(post_id: post1.id, person_id: person2.id) + Reader.create(post_id: post1.id, person_id: person3.id) + Reader.create(post_id: post1.id, person_id: person4.id) + + Reader.create(post_id: post2.id, person_id: person1.id) + Reader.create(post_id: post2.id, person_id: person2.id) + Reader.create(post_id: post2.id, person_id: person3.id) + Reader.create(post_id: post2.id, person_id: person4.id) + + active_persons = Person.joins(:readers).joins(:posts).distinct(true).where("posts.title" => "active") + + assert_equal active_persons.map(&:followers_count).reduce(:+), 10 + assert_equal active_persons.sum(:followers_count), 10 + assert_equal active_persons.sum(:followers_count), active_persons.map(&:followers_count).reduce(:+) + 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).id], person.first_posts.map(&:id) + + readers(:michael_authorless).update(first_post_id: 1) + assert_equal [posts(:thinking).id], person.reload.first_posts.map(&:id) + 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 + + def test_build_for_has_many_through_association + organization = organizations(:nsa) + author = organization.author + post_direct = author.posts.build + post_through = organization.posts.build + assert_equal post_direct.author_id, post_through.author_id + end + + def test_has_many_through_with_scope_that_should_not_be_fully_merged + Club.has_many :distinct_memberships, -> { distinct }, class_name: "Membership" + Club.has_many :special_favourites, through: :distinct_memberships, source: :member + + assert_nil Club.new.special_favourites.distinct_value + end + + def test_has_many_through_do_not_cache_association_reader_if_the_though_method_has_default_scopes + member = Member.create! + club = Club.create! + TenantMembership.create!( + member: member, + club: club + ) + + TenantMembership.current_member = member + + tenant_clubs = member.tenant_clubs + assert_equal [club], tenant_clubs + + TenantMembership.current_member = nil + + other_member = Member.create! + other_club = Club.create! + TenantMembership.create!( + member: other_member, + club: other_club + ) + + tenant_clubs = other_member.tenant_clubs + assert_equal [other_club], tenant_clubs + ensure + TenantMembership.current_member = nil + end + + def test_has_many_through_with_scope_that_has_joined_same_table_with_parent_relation + assert_equal authors(:david), Author.joins(:comments_for_first_author).take + end + + def test_has_many_through_with_left_joined_same_table_with_through_table + assert_equal [comments(:eager_other_comment1)], authors(:mary).comments.left_joins(:post) + end + + def test_has_many_through_with_unscope_should_affect_to_through_scope + assert_equal [comments(:eager_other_comment1)], authors(:mary).unordered_comments + end + + def test_has_many_through_with_scope_should_accept_string_and_hash_join + assert_equal authors(:david), Author.joins({ comments_for_first_author: :post }, "inner join posts posts_alias on authors.id = posts_alias.author_id").eager_load(:categories).take + end + + def test_has_many_through_with_scope_should_respect_table_alias + family = Family.create! + users = 3.times.map { User.create! } + FamilyTree.create!(member: users[0], family: family) + FamilyTree.create!(member: users[1], family: family) + FamilyTree.create!(member: users[2], family: family, token: "wat") + + assert_equal 2, users[0].family_members.to_a.size + assert_equal 0, users[2].family_members.to_a.size + end + + def test_through_scope_is_affected_by_unscoping + author = authors(:david) + + expected = author.comments.to_a + FirstPost.unscoped do + assert_equal expected.sort_by(&:id), author.comments_on_first_posts.sort_by(&:id) + end + end + + def test_through_scope_isnt_affected_by_scoping + author = authors(:david) + + expected = author.comments_on_first_posts.to_a + FirstPost.where(id: 2).scoping do + author.comments_on_first_posts.reset + assert_equal expected.sort_by(&:id), author.comments_on_first_posts.sort_by(&:id) + end + end + + def test_incorrectly_ordered_through_associations + assert_raises(ActiveRecord::HasManyThroughOrderError) do + DeveloperWithIncorrectlyOrderedHasManyThrough.create( + companies: [Company.create] + ) + end + end + + def test_has_many_through_update_ids_with_conditions + author = Author.create!(name: "Bill") + category = categories(:general) + + author.update( + special_categories_with_condition_ids: [category.id], + nonspecial_categories_with_condition_ids: [category.id] + ) + + assert_equal [category.id], author.special_categories_with_condition_ids + assert_equal [category.id], author.nonspecial_categories_with_condition_ids + + author.update(nonspecial_categories_with_condition_ids: []) + author.reload + + assert_equal [category.id], author.special_categories_with_condition_ids + assert_equal [], author.nonspecial_categories_with_condition_ids + end + + def test_single_has_many_through_association_with_unpersisted_parent_instance + post_with_single_has_many_through = Class.new(Post) do + def self.name; "PostWithSingleHasManyThrough"; end + has_many :subscriptions, through: :author + end + post = post_with_single_has_many_through.new + + post.author = authors(:mary) + book1 = Book.create!(name: "essays on single has many through associations 1") + post.author.books << book1 + subscription1 = Subscription.first + book1.subscriptions << subscription1 + assert_equal [subscription1], post.subscriptions.to_a + + post.author = authors(:bob) + book2 = Book.create!(name: "essays on single has many through associations 2") + post.author.books << book2 + subscription2 = Subscription.second + book2.subscriptions << subscription2 + assert_equal [subscription2], post.subscriptions.to_a + end + + def test_nested_has_many_through_association_with_unpersisted_parent_instance + post_with_nested_has_many_through = Class.new(Post) do + def self.name; "PostWithNestedHasManyThrough"; end + has_many :books, through: :author + has_many :subscriptions, through: :books + end + post = post_with_nested_has_many_through.new + + post.author = authors(:mary) + book1 = Book.create!(name: "essays on nested has many through associations 1") + post.author.books << book1 + subscription1 = Subscription.first + book1.subscriptions << subscription1 + assert_equal [subscription1], post.subscriptions.to_a + + post.author = authors(:bob) + book2 = Book.create!(name: "essays on nested has many through associations 2") + post.author.books << book2 + subscription2 = Subscription.second + book2.subscriptions << subscription2 + assert_equal [subscription2], post.subscriptions.to_a + end + + private + def make_model(name) + Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } + 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, anonymous_class: lesson + lesson_student.belongs_to :student, anonymous_class: student + lesson.has_many :lesson_students, anonymous_class: lesson_student + lesson.has_many :students, through: :lesson_students, anonymous_class: student + [lesson, lesson_student, student] + 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..bf574f6637 --- /dev/null +++ b/activerecord/test/cases/associations/has_one_associations_test.rb @@ -0,0 +1,786 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/computer" +require "models/project" +require "models/company" +require "models/ship" +require "models/pirate" +require "models/car" +require "models/bulb" +require "models/author" +require "models/image" +require "models/post" + +class HasOneAssociationsTest < ActiveRecord::TestCase + self.use_transactional_tests = false unless supports_savepoints? + fixtures :accounts, :companies, :developers, :projects, :developers_projects, :ships, :pirates, :authors, :author_addresses + + def setup + Account.destroyed_account_ids.clear + end + + def test_has_one + firm = companies(:first_firm) + first_account = Account.find(1) + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + assert_equal first_account, firm.account + assert_equal first_account.credit_limit, firm.account.credit_limit + end + end + + def test_has_one_does_not_use_order_by + ActiveRecord::SQLCounter.clear_log + companies(:first_firm).account + ensure + log_all = ActiveRecord::SQLCounter.log_all + assert log_all.all? { |sql| /order by/i !~ sql }, "ORDER BY was used in the query: #{log_all}" + end + + def test_has_one_cache_nils + firm = companies(:another_firm) + assert_queries(1) { assert_nil firm.account } + assert_no_queries { assert_nil firm.account } + + firms = Firm.includes(:account).to_a + assert_no_queries { 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_nullification_on_destroyed_association + developer = Developer.create!(name: "Someone") + ship = Ship.create!(name: "Planet Caravan", developer: developer) + ship.destroy + assert_not_predicate ship, :persisted? + assert_not_predicate developer, :persisted? + 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_predicate 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_not_empty firm.errors + assert_equal "Cannot delete record because a dependent account exists", firm.errors[:base].first + assert RestrictedWithErrorFirm.exists?(name: "restrict") + assert_predicate firm.account, :present? + end + + def test_restrict_with_error_with_locale + I18n.backend = I18n::Backend::Simple.new + I18n.backend.store_translations "en", activerecord: { attributes: { restricted_with_error_firm: { account: "firm account" } } } + firm = RestrictedWithErrorFirm.create!(name: "restrict") + firm.create_account(credit_limit: 10) + + assert_not_nil firm.account + + firm.destroy + + assert_not_empty firm.errors + assert_equal "Cannot delete record because a dependent firm account exists", firm.errors[:base].first + assert RestrictedWithErrorFirm.exists?(name: "restrict") + assert_predicate firm.account, :present? + ensure + I18n.backend.reload! + 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 + # Load schema information so we don't query below if running just this test. + Account.define_attribute_methods + + firm = Firm.new + assert_no_queries do + firm.build_account + end + 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) + scope = pirate.association(:foo_bulb).scope.where_values_hash + + bulb = pirate.build_foo_bulb + assert_not_equal scope, bulb.scope_after_initialize.where_values_hash + + bulb = pirate.create_foo_bulb + assert_not_equal scope, bulb.scope_after_initialize.where_values_hash + + bulb = pirate.create_foo_bulb! + assert_not_equal scope, bulb.scope_after_initialize.where_values_hash + 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_create_with_inexistent_foreign_key_failing + firm = Firm.create(name: "GlobalMegaCorp") + + assert_raises(ActiveRecord::UnknownAttributeError) do + firm.create_account_with_inexistent_foreign_key + end + end + + def test_create_when_parent_is_new_raises + firm = Firm.new + error = assert_raise(ActiveRecord::RecordNotSaved) do + firm.create_account + end + + assert_equal "You cannot call create unless the parent is saved", error.message + end + + def test_reload_association + odegy = companies(:odegy) + + assert_equal 53, odegy.account.credit_limit + Account.where(id: odegy.account.id).update_all(credit_limit: 80) + assert_equal 53, odegy.account.credit_limit + + assert_equal 80, odegy.reload_account.credit_limit + end + + def test_reload_association_with_query_cache + odegy_id = companies(:odegy).id + + connection = ActiveRecord::Base.connection + connection.enable_query_cache! + connection.clear_query_cache + + # Populate the cache with a query + odegy = Company.find(odegy_id) + # Populate the cache with a second query + odegy.account + + assert_equal 2, connection.query_cache.size + + # Clear the cache and fetch the account again, populating the cache with a query + assert_queries(1) { odegy.reload_account } + + # This query is not cached anymore, so it should make a real SQL query + assert_queries(1) { Company.find(odegy_id) } + ensure + ActiveRecord::Base.connection.disable_query_cache! + 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_predicate a, :persisted? + assert_equal a, firm.account + assert_equal a, firm.account + firm.association(:account).reload + assert_equal a, firm.account + 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_predicate 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_predicate 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_predicate new_ship, :new_record? + assert_nil orig_ship.pirate_id + assert_not 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_predicate new_ship, :new_record? + assert_predicate orig_ship, :destroyed? + end + + def test_creation_failure_due_to_new_record_should_raise_error + pirate = pirates(:redbeard) + new_ship = Ship.new + + error = assert_raise(ActiveRecord::RecordNotSaved) do + pirate.ship = new_ship + end + + assert_equal "Failed to save the new associated ship.", error.message + 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_not_predicate pirate.ship, :valid? + error = 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 + assert_equal "Failed to remove the existing associated ship. " \ + "The record failed to save after its foreign key was set to nil.", error.message + end + + def test_replacement_failure_due_to_new_record_should_raise_error + pirate = pirates(:blackbeard) + new_ship = Ship.new + + error = assert_raise(ActiveRecord::RecordNotSaved) do + pirate.ship = new_ship + end + + assert_equal "Failed to save the new associated ship.", error.message + 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_predicate 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 to nullify the old ship, one query to update the new ship + 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_loading_for_new_record + post = Post.create!(author_id: 42, title: "foo", body: "bar") + author = Author.new(id: 42) + assert_equal post, author.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 + + def test_with_polymorphic_has_one_with_custom_columns_name + post = Post.create! title: "foo", body: "bar" + image = Image.create! + + post.main_image = image + post.reload + + assert_equal image, post.main_image + 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 + + class SpecialBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "SpecialAuthor" + has_one :subscription, class_name: "SpecialSupscription", foreign_key: "subscriber_id" + + enum status: [:proposed, :written, :published] + end + + class SpecialAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "SpecialBook", foreign_key: "author_id" + end + + class SpecialSupscription < ActiveRecord::Base + self.table_name = "subscriptions" + belongs_to :book, class_name: "SpecialBook" + end + + def test_association_enum_works_properly + author = SpecialAuthor.create!(name: "Test") + book = SpecialBook.create!(status: "published") + author.book = book + + assert_equal "published", book.status + assert_not_equal 0, SpecialAuthor.joins(:book).where(books: { status: "published" }).count + end + + def test_association_enum_works_properly_with_nested_join + author = SpecialAuthor.create!(name: "Test") + book = SpecialBook.create!(status: "published") + author.book = book + + where_clause = { books: { subscriptions: { subscriber_id: nil } } } + assert_nothing_raised do + SpecialAuthor.joins(book: :subscription).where.not(where_clause) + end + end + + class DestroyByParentBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "DestroyByParentAuthor" + before_destroy :dont, unless: :destroyed_by_association + + def dont + throw(:abort) + end + end + + class DestroyByParentAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "DestroyByParentBook", foreign_key: "author_id", dependent: :destroy + end + + test "destroyed_by_association set in child destroy callback on parent destroy" do + author = DestroyByParentAuthor.create!(name: "Test") + book = DestroyByParentBook.create!(author: author) + + author.destroy + + assert_not DestroyByParentBook.exists?(book.id) + end + + test "destroyed_by_association set in child destroy callback on replace" do + author = DestroyByParentAuthor.create!(name: "Test") + book = DestroyByParentBook.create!(author: author) + + author.book = DestroyByParentBook.create! + author.save! + + assert_not DestroyByParentBook.exists?(book.id) + end + + class UndestroyableBook < ActiveRecord::Base + self.table_name = "books" + belongs_to :author, class_name: "DestroyableAuthor" + before_destroy :dont + + def dont + throw(:abort) + end + end + + class DestroyableAuthor < ActiveRecord::Base + self.table_name = "authors" + has_one :book, class_name: "UndestroyableBook", foreign_key: "author_id", dependent: :destroy + end + + def test_dependency_should_halt_parent_destruction + author = DestroyableAuthor.create!(name: "Test") + UndestroyableBook.create!(author: author) + + assert_no_difference ["DestroyableAuthor.count", "UndestroyableBook.count"] do + assert_not author.destroy + 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..69b4872519 --- /dev/null +++ b/activerecord/test/cases/associations/has_one_through_associations_test.rb @@ -0,0 +1,429 @@ +# frozen_string_literal: true + +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" +require "models/categorization" +require "models/customer" +require "models/carrier" +require "models/shop_account" +require "models/customer_carrier" + +class HasOneThroughAssociationsTest < ActiveRecord::TestCase + fixtures :member_types, :members, :clubs, :memberships, :sponsors, :organizations, :minivans, + :dashboards, :speedometers, :authors, :author_addresses, :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_has_one_through_executes_limited_query + boring_club = clubs(:boring_club) + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + assert_equal boring_club, @member.general_club + end + 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 + new_member = Member.create(name: "Chris") + new_club = new_member.association(:club).build + assert new_member.current_membership + assert_equal new_club, new_member.club + assert_predicate new_club, :new_record? + assert_predicate new_member.current_membership, :new_record? + assert new_member.save + assert_predicate new_club, :persisted? + assert_predicate new_member.current_membership, :persisted? + 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_building_multiple_associations_builds_through_record + member_type = MemberType.create! + member = Member.create! + member_detail_with_one_association = MemberDetail.new(member_type: member_type) + assert_predicate member_detail_with_one_association.member, :new_record? + member_detail_with_two_associations = MemberDetail.new(member_type: member_type, admittable: member) + assert_predicate member_detail_with_two_associations.member, :new_record? + end + + def test_creating_multiple_associations_creates_through_record + member_type = MemberType.create! + member = Member.create! + member_detail_with_one_association = MemberDetail.create!(member_type: member_type) + assert_not_predicate member_detail_with_one_association.member, :new_record? + member_detail_with_two_associations = MemberDetail.create!(member_type: member_type, admittable: member) + assert_not_predicate member_detail_with_two_associations.member, :new_record? + 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_nil @member.current_membership + assert_nil @member.club + end + + def test_set_record_after_delete_association + @member.club = nil + @member.club = clubs(:moustache_club) + @member.reload + assert_equal clubs(:moustache_club), @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_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_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_includes @organization.members, @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_includes @organization.members, @member + assert_not_includes @new_organization.members, @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_not_includes @organization.members, @member + assert_includes @new_organization.members, @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_predicate @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 + @member_detail.association(:member_type).reload + assert_not_nil @member_detail.member_type + end + + @member_detail.member.destroy + assert_queries(1) do + @member_detail.association(:member_type).reload + assert_nil @member_detail.member_type + 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_polymorphic_association + assert_raise(ActiveRecord::HasOneAssociationPolymorphicThroughError) do + @member.premium_club + 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_not_predicate proxy, :stale_target? + assert_equal dashboards(:cool_first), minivan.dashboard + + minivan.speedometer_id = speedometers(:second).id + + assert_predicate 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_predicate 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 + + def test_has_one_through_do_not_cache_association_reader_if_the_though_method_has_default_scopes + customer = Customer.create! + carrier = Carrier.create! + customer_carrier = CustomerCarrier.create!( + customer: customer, + carrier: carrier, + ) + account = ShopAccount.create!(customer_carrier: customer_carrier) + + CustomerCarrier.current_customer = customer + + account_carrier = account.carrier + assert_equal carrier, account_carrier + + CustomerCarrier.current_customer = nil + + other_carrier = Carrier.create! + other_customer = Customer.create! + other_customer_carrier = CustomerCarrier.create!( + customer: other_customer, + carrier: other_carrier, + ) + other_account = ShopAccount.create!(customer_carrier: other_customer_carrier) + + account_carrier = other_account.carrier + assert_equal other_carrier, account_carrier + ensure + CustomerCarrier.current_customer = nil + 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..c33dcdee61 --- /dev/null +++ b/activerecord/test/cases/associations/inner_join_association_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +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, :author_addresses, :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_does_not_table_name_collide_on_duplicate_associations_with_left_outer_joins + sql = Person.joins(agents: :agents).left_outer_joins(agents: :agents).to_sql + assert_match(/agents_people_4/i, sql) + end + + def test_construct_finder_sql_does_not_table_name_collide_with_string_joins + sql = Person.joins(:agents).joins("JOIN people agents_people ON agents_people.primary_contact_id = people.id").to_sql + assert_match(/agents_people_2/i, sql) + end + + def test_construct_finder_sql_does_not_table_name_collide_with_aliased_joins + people = Person.arel_table + agents = people.alias("agents_people") + constraint = agents[:primary_contact_id].eq(people[:id]) + sql = Person.joins(:agents).joins(agents.create_join(agents, agents.create_on(constraint))).to_sql + assert_match(/agents_people_2/i, sql) + 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?(&: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_not 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_not 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_not 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.start_with?("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_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + assert_not_empty scope.where("comments.type" => "SubSpecialComment") + end + + def test_find_with_conditions_on_reflection + assert_not_empty posts(:welcome).comments + assert Post.joins(:nonexistent_comments).where(id: posts(:welcome).id).empty? # [sic!] + end + + def test_find_with_conditions_on_through_reflection + assert_not_empty posts(:welcome).tags + assert_empty Post.joins(:misc_tags).where(id: posts(:welcome).id) + 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..eb4dc73423 --- /dev/null +++ b/activerecord/test/cases/associations/inverse_associations_test.rb @@ -0,0 +1,762 @@ +# frozen_string_literal: true + +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" +require "models/admin" +require "models/admin/account" +require "models/admin/user" +require "models/developer" +require "models/company" +require "models/project" +require "models/author" +require "models/post" +require "models/department" +require "models/hotel" + +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 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 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_many_and_belongs_to_should_find_inverse_automatically_for_model_in_module + account_reflection = Admin::Account.reflect_on_association(:users) + user_reflection = Admin::User.reflect_on_association(:account) + + assert account_reflection.has_inverse?, "The Admin::Account reflection should have an inverse" + assert_equal user_reflection, account_reflection.inverse_of, "The Admin::Account reflection's inverse should be the Admin::User 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 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 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 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_many_and_belongs_to_should_find_inverse_automatically_for_sti + author_reflection = Author.reflect_on_association(:posts) + author_child_reflection = Author.reflect_on_association(:special_posts) + post_reflection = Post.reflect_on_association(:author) + + assert_respond_to author_reflection, :has_inverse? + assert author_reflection.has_inverse?, "The Author reflection should have an inverse" + assert_equal post_reflection, author_reflection.inverse_of, "The Author reflection's inverse should be the Post reflection" + + assert_respond_to author_child_reflection, :has_inverse? + assert author_child_reflection.has_inverse?, "The Author reflection should have an inverse" + assert_equal post_reflection, author_child_reflection.inverse_of, "The Author reflection's inverse should be the Post 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 = "Fennec foxes are the smallest of the foxes." + assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body" + + comment.body = "Kittens are adorable." + 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 = "Fennec foxes are the smallest of the foxes." + assert_equal rating.comment.body, comment.body, "Changing the Comment's body on the association should change the original Comment's body" + + comment.body = "Kittens are adorable." + 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_not sponsor_reflection.has_inverse?, "A polymorphic association should not find an inverse automatically" + + club_reflection = Club.reflect_on_association(:members) + + assert_not club_reflection.has_inverse?, "A has_many_through association should not find an inverse automatically" + end + + def test_polymorphic_has_one_should_find_inverse_automatically + man_reflection = Man.reflect_on_association(:polymorphic_face_without_inverse) + + assert_predicate man_reflection, :has_inverse? + end +end + +class InverseAssociationTests < ActiveRecord::TestCase + def test_should_allow_for_inverse_of_options_in_associations + assert_nothing_raised do + Class.new(ActiveRecord::Base).has_many(:wheels, inverse_of: :car) + end + + assert_nothing_raised do + Class.new(ActiveRecord::Base).has_one(:engine, inverse_of: :car) + end + + assert_nothing_raised 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_predicate has_one_with_inverse_ref, :has_inverse? + + has_many_with_inverse_ref = Man.reflect_on_association(:interests) + assert_predicate has_many_with_inverse_ref, :has_inverse? + + belongs_to_with_inverse_ref = Face.reflect_on_association(:man) + assert_predicate belongs_to_with_inverse_ref, :has_inverse? + + has_one_without_inverse_ref = Club.reflect_on_association(:sponsor) + assert_not_predicate has_one_without_inverse_ref, :has_inverse? + + has_many_without_inverse_ref = Club.reflect_on_association(:memberships) + assert_not_predicate has_many_without_inverse_ref, :has_inverse? + + belongs_to_without_inverse_ref = Sponsor.reflect_on_association(:sponsor_club) + assert_not_predicate belongs_to_without_inverse_ref, :has_inverse? + 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 + + def test_polymorphic_associations_dont_attempt_to_find_inverse_of + belongs_to_ref = Sponsor.reflect_on_association(:sponsor) + assert_raise(ArgumentError) { belongs_to_ref.klass } + assert_nil belongs_to_ref.inverse_of + + belongs_to_ref = Face.reflect_on_association(:human) + assert_raise(ArgumentError) { belongs_to_ref.klass } + assert_nil belongs_to_ref.inverse_of + end + + def test_this_inverse_stuff + firm = Firm.create!(name: "Adequate Holdings") + Project.create!(name: "Project 1", firm: firm) + Developer.create!(name: "Gorbypuff", firm: firm) + + new_project = Project.last + assert Project.reflect_on_association(:lead_developer).inverse_of.present?, "Expected inverse of to be present" + assert new_project.lead_developer.present?, "Expected lead developer to be present on the project" + 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, :posts, :authors, :author_addresses + + 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_every_child_on_find_for_sti + a = authors(:david) + ps = a.posts + ps.each do |p| + assert_equal a.name, p.author.name, "Name of man should be the same before changes to parent instance" + a.name = "Bongo" + assert_equal a.name, p.author.name, "Name of man should be the same after changes to parent instance" + p.author.name = "Mungo" + assert_equal a.name, p.author.name, "Name of man should be the same after changes to child-owned instance" + end + + sps = a.special_posts + sps.each do |sp| + assert_equal a.name, sp.author.name, "Name of man should be the same before changes to parent instance" + a.name = "Bongo" + assert_equal a.name, sp.author.name, "Name of man should be the same after changes to parent instance" + sp.author.name = "Mungo" + assert_equal a.name, sp.author.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_nil 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_predicate 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! + + exception = assert_raise(ActiveRecord::RecordNotFound) { man.interests.load.find() } + + assert_equal exception.model, "Interest" + assert_equal exception.primary_key, "id" + 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_not_predicate man, :persisted? + end + + def test_inverse_instance_should_be_set_before_find_callbacks_are_run + reset_callbacks(Interest, :find) do + Interest.after_find { raise unless association(:man).loaded? && man.present? } + + assert_predicate Man.first.interests.reload, :any? + assert_predicate Man.includes(:interests).first.interests, :any? + assert_predicate Man.joins(:interests).includes(:interests).first.interests, :any? + end + end + + def test_inverse_instance_should_be_set_before_initialize_callbacks_are_run + reset_callbacks(Interest, :initialize) do + Interest.after_initialize { raise unless association(:man).loaded? && man.present? } + + assert_predicate Man.first.interests.reload, :any? + assert_predicate Man.includes(:interests).first.interests, :any? + assert_predicate Man.joins(:interests).includes(:interests).first.interests, :any? + end + end + + def reset_callbacks(target, type) + old_callbacks = target.send(:get_callbacks, type).deep_dup + yield + ensure + target.send(:set_callbacks, type, old_callbacks) if old_callbacks + 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_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_inversed_instance_should_not_be_reloaded_after_stale_state_changed_with_validation + face = Face.new man: Man.new + + old_inversed_man = face.man + face.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 { 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 { 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 + + def test_favors_has_one_associations_for_inverse_of + inverse_name = Post.reflect_on_association(:author).inverse_of.name + assert_equal :post, inverse_name + end + + def test_finds_inverse_of_for_plural_associations + inverse_name = Department.reflect_on_association(:hotel).inverse_of.name + assert_equal :departments, inverse_name + 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 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 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..9d1c73c33b --- /dev/null +++ b/activerecord/test/cases/associations/join_model_test.rb @@ -0,0 +1,778 @@ +# frozen_string_literal: true + +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_tests = false unless supports_savepoints? + + fixtures :posts, :authors, :author_addresses, :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_includes authors(:david).categories, categories(:general) + end + + def test_has_many_inherited + assert_includes authors(:mary).categories, categories(:sti_test) + end + + def test_inherited_has_many + assert_includes categories(:sti_test).authors, authors(:mary) + end + + def test_has_many_distinct_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_distinct_through_count + author = authors(:mary) + assert_not_predicate 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_not_predicate authors(:mary).unique_categorized_posts, :loaded? + end + + def test_has_many_distinct_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 { 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 = SubAbstractStiPost.create title: "SubAbstractStiPost", body: "SubAbstractStiPost body" + assert_instance_of SubAbstractStiPost, post + + tagging = tags(:misc).taggings.create(taggable: post) + assert_equal "SubAbstractStiPost", 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 + posts(:welcome).association(:tagging).reload + assert_nil posts(:welcome).tagging + 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 + posts(:welcome).association(:tagging).reload + assert_nil posts(:welcome).tagging + 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_has_many_polymorphic_associations_merges_through_scope + Tag.has_many :null_taggings, -> { none }, class_name: :Tagging + Tag.has_many :null_tagged_posts, through: :null_taggings, source: "taggable", source_type: "Post" + assert_equal [], tags(:general).null_tagged_posts + assert_not_equal [], tags(:general).tagged_posts + 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(&:id), authors(:david).taggings_2.sort_by(&:id) + end + + def test_has_many_through_polymorphic_has_many + assert_equal taggings(:welcome_general, :thinking_general), authors(:david).taggings.distinct.sort_by(&: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.uniq.sort_by(&: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_predicate author.comments, :present? + assert_predicate author.nonexistent_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_predicate saved_post, :persisted? + assert_includes saved_post.tags, new_tag + + assert_predicate new_tag, :persisted? + assert_includes saved_post.reload.tags.reload, 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_not_predicate new_post, :persisted? + assert_predicate saved_tag, :persisted? + assert_includes new_post.tags, saved_tag + + new_post.save! + assert_predicate new_post, :persisted? + assert_includes new_post.reload.tags.reload, saved_tag + + assert_not_predicate posts(:thinking).tags.build, :persisted? + assert_not_predicate 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 }, + "Expected a Tag in tags collection, got #{wrong.class}.") + assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, + "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.reload.size) + + assert_kind_of Tag, post_thinking.tags.create!(name: "foo") + assert_nil(wrong = post_thinking.tags.detect { |t| t.class != Tag }, + "Expected a Tag in tags collection, got #{wrong.class}.") + assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, + "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.reload.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 }, + "Expected a Tag in tags collection, got #{wrong.class}.") + assert_nil(wrong = post_thinking.taggings.detect { |t| t.class != Tagging }, + "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.reload.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_not_predicate 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_not_predicate 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.reload.size) + + assert_nothing_raised { book_awdr.references.delete(book) } + assert_equal(count, book_awdr.references.size) + assert_equal(count, book_awdr.references.reload.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.reload.size) + assert_equal(count + 1, post_thinking.reload.tags.reload.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.reload.size) + assert_equal(count, post_thinking.taggings.reload.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.reload.size) + + assert_nothing_raised { post_thinking.tags.delete(doomed, doomed2, quaked) } + assert_equal(count, post_thinking.tags.size) + assert_equal(count, post_thinking.tags.reload.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_integer_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_distinct_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_includes p.taggings, expected } + assert_includes posts(:welcome).taggings, 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_includes taggables, items(:dvd) + assert_includes taggables, 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_predicate david.categories, :loaded? + assert_includes david.categories, 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_not_predicate david.categories, :loaded? + assert_queries(1) do + assert_includes david.categories, category + end + assert_not_predicate 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_not_predicate david.categories, :loaded? + assert_not 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 + + def test_proper_error_message_for_eager_load_and_includes_association_errors + includes_error = assert_raises(ActiveRecord::ConfigurationError) { + Post.includes(:nonexistent_relation).where(nonexistent_relation: { name: "Rochester" }).find(1) + } + assert_equal("Can't join 'Post' to association named 'nonexistent_relation'; perhaps you misspelled it?", includes_error.message) + + eager_load_error = assert_raises(ActiveRecord::ConfigurationError) { + Post.eager_load(:nonexistent_relation).where(nonexistent_relation: { name: "Rochester" }).find(1) + } + assert_equal("Can't join 'Post' to association named 'nonexistent_relation'; perhaps you misspelled it?", eager_load_error.message) + + includes_and_eager_load_error = assert_raises(ActiveRecord::ConfigurationError) { + Post.eager_load(:nonexistent_relation).includes(:nonexistent_relation).where(nonexistent_relation: { name: "Rochester" }).find(1) + } + assert_equal("Can't join 'Post' to association named 'nonexistent_relation'; perhaps you misspelled it?", includes_and_eager_load_error.message) + 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/left_outer_join_association_test.rb b/activerecord/test/cases/associations/left_outer_join_association_test.rb new file mode 100644 index 0000000000..0e54e8c1b0 --- /dev/null +++ b/activerecord/test/cases/associations/left_outer_join_association_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" +require "models/author" +require "models/essay" +require "models/category" +require "models/categorization" +require "models/person" + +class LeftOuterJoinAssociationTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :essays, :posts, :comments, :categorizations, :people + + def test_construct_finder_sql_applies_aliases_tables_on_association_conditions + result = Author.left_outer_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 + queries = capture_sql do + Person.left_outer_joins(agents: { agents: :agents }) + .left_outer_joins(agents: { agents: { primary_contact: :agents } }).to_a + end + assert queries.any? { |sql| /agents_people_4/i.match?(sql) } + end + end + + def test_left_outer_joins_count_is_same_as_size_of_loaded_results + assert_equal 17, Post.left_outer_joins(:comments).to_a.size + assert_equal 17, Post.left_outer_joins(:comments).count + end + + def test_left_joins_aliases_left_outer_joins + assert_equal Post.left_outer_joins(:comments).to_sql, Post.left_joins(:comments).to_sql + end + + def test_left_outer_joins_return_has_value_for_every_comment + all_post_ids = Post.pluck(:id) + assert_equal all_post_ids, all_post_ids & Post.left_outer_joins(:comments).pluck(:id) + end + + def test_left_outer_joins_actually_does_a_left_outer_join + queries = capture_sql { Author.left_outer_joins(:posts).to_a } + assert queries.any? { |sql| /LEFT OUTER JOIN/i.match?(sql) } + end + + def test_construct_finder_sql_ignores_empty_left_outer_joins_hash + queries = capture_sql { Author.left_outer_joins({}).to_a } + assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) } + end + + def test_construct_finder_sql_ignores_empty_left_outer_joins_array + queries = capture_sql { Author.left_outer_joins([]).to_a } + assert queries.none? { |sql| /LEFT OUTER JOIN/i.match?(sql) } + end + + def test_left_outer_joins_forbids_to_use_string_as_argument + assert_raise(ArgumentError) { Author.left_outer_joins('LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id"').to_a } + end + + def test_join_conditions_added_to_join_clause + queries = capture_sql { Author.left_outer_joins(:essays).to_a } + assert queries.any? { |sql| /writer_type.*?=.*?(Author|\?|\$1|\:a1)/i.match?(sql) } + assert queries.none? { |sql| /WHERE/i.match?(sql) } + end + + def test_find_with_sti_join + scope = Post.left_outer_joins(:special_comments).where(id: posts(:sti_comments).id) + + # The join should match SpecialComment and its subclasses only + assert_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + assert_not_empty scope.where("comments.type" => "SubSpecialComment") + end + + def test_does_not_override_select + authors = Author.select("authors.name, #{%{(authors.author_address_id || ' ' || authors.author_address_extra_id) as addr_id}}").left_outer_joins(:posts) + assert_predicate authors, :any? + assert_respond_to authors.first, :addr_id + 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).left_outer_joins(:special_categorizations) + 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..5821744530 --- /dev/null +++ b/activerecord/test/cases/associations/nested_through_associations_test.rb @@ -0,0 +1,622 @@ +# frozen_string_literal: true + +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" +require "models/hotel" +require "models/department" +require "models/chef" +require "models/cake_designer" +require "models/drink_designer" + +class NestedThroughAssociationsTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :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_empty authors + 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 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_empty members + 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 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_empty members + 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_empty authors + authors = Author.joins(:similar_posts).where("taggings_authors_join.taggable_type" => "FakeModel") + assert_empty authors + end + + def test_nested_has_many_through_with_scope_on_polymorphic_reflection + authors = Author.joins(:ordered_posts).where("posts.id" => posts(:misc_by_bob).id) + assert_equal [authors(:mary), authors(:bob)], authors.distinct.sort_by(&:id) + 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_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + assert_not_empty scope.where("comments.type" => "SubSpecialComment") + 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_empty scope.where("comments.type" => "Comment") + assert_not_empty scope.where("comments.type" => "SpecialComment") + 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::HasOneThroughNestedAssociationsAreReadonly) 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_empty Author.where("tags.id" => 100).joins(:misc_post_first_blue_tags) + + 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_not_empty c.post_taggings + c.save + assert_not_empty c.post_taggings + end + + def test_polymorphic_has_many_through_when_through_association_has_not_loaded + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + Hotel.create!(departments: [department]) + hotel = Hotel.includes(:cake_designers, :drink_designers).take + + assert_equal [cake_designer], hotel.cake_designers + assert_equal [drink_designer], hotel.drink_designers + end + + def test_polymorphic_has_many_through_when_through_association_has_already_loaded + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + Hotel.create!(departments: [department]) + hotel = Hotel.includes(:chefs, :cake_designers, :drink_designers).take + + assert_equal [cake_designer], hotel.cake_designers + assert_equal [drink_designer], hotel.drink_designers + end + + def test_polymorphic_has_many_through_joined_different_table_twice + cake_designer = CakeDesigner.create!(chef: Chef.new) + drink_designer = DrinkDesigner.create!(chef: Chef.new) + department = Department.create!(chefs: [cake_designer.chef, drink_designer.chef]) + hotel = Hotel.create!(departments: [department]) + + assert_equal hotel, Hotel.joins(:cake_designers, :drink_designers).take + 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..c7a78e6bc4 --- /dev/null +++ b/activerecord/test/cases/associations/required_test.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "cases/helper" + +class RequiredAssociationsTest < ActiveRecord::TestCase + self.use_transactional_tests = 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.drop_table "parents", if_exists: true + @connection.drop_table "children", if_exists: true + end + + test "belongs_to associations can be optional by default" do + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = false + + 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 + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + 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 must exist"], record.errors.full_messages + + record.parent = Parent.new + assert record.save + end + + test "belongs_to associations can be required by default" do + original_value = ActiveRecord::Base.belongs_to_required_by_default + ActiveRecord::Base.belongs_to_required_by_default = true + + model = subclass_of(Child) do + belongs_to :parent, inverse_of: false, + class_name: "RequiredAssociationsTest::Parent" + end + + record = model.new + assert_not record.save + assert_equal ["Parent must exist"], record.errors.full_messages + + record.parent = Parent.new + assert record.save + ensure + ActiveRecord::Base.belongs_to_required_by_default = original_value + 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 must exist"], record.errors.full_messages + + record.child = Child.new + assert record.save + end + + test "required has_one associations have a correct error message" do + model = subclass_of(Parent) do + has_one :child, required: true, inverse_of: false, + class_name: "RequiredAssociationsTest::Child" + end + + record = model.create + assert_equal ["Child must exist"], record.errors.full_messages + end + + test "required belongs_to associations have a correct error message" do + model = subclass_of(Child) do + belongs_to :parent, required: true, inverse_of: false, + class_name: "RequiredAssociationsTest::Parent" + end + + record = model.create + assert_equal ["Parent must exist"], record.errors.full_messages + 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..081da95df7 --- /dev/null +++ b/activerecord/test/cases/associations_test.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +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/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, :authors, :author_addresses, :author_favorites + + 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_subselect + author = authors :david + favs = author.author_favorites + fav2 = author.author_favorites.where(author: Author.where(id: author.id)).to_a + assert_equal favs, fav2 + 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 + assert_predicate 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") + 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" + + firm.clients.reload + + assert_not firm.clients.empty?, "New firm should have reloaded client objects" + assert_equal 1, firm.clients.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_not using_limitable_reflections.call(has_many_reflections), "All has many style associations are not limitable" + assert_not using_limitable_reflections.call(mixed_reflections), "No collection associations (has many style) should pass" + end + + def test_association_with_references + firm = companies(:first_firm) + assert_includes firm.association_with_references.references_values, "foo" + end +end + +class AssociationProxyTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :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_not_predicate david.posts, :loaded? + assert_includes david.posts, post + end + + def test_push_has_many_through_does_not_load_target + david = authors(:david) + + david.categories << categories(:technology) + assert_not_predicate david.categories, :loaded? + assert_includes david.categories, 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_not_predicate david.posts, :loaded? + david.save + assert_not_predicate david.posts, :loaded? + assert_includes david.posts, 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_predicate 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_predicate 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_not_predicate david.projects, :loaded? + david.update_columns(created_at: Time.now) + assert_not_predicate david.projects, :loaded? + end + + def test_load_does_load_target + david = developers(:david) + + assert_not_predicate david.projects, :loaded? + david.projects.load + assert_predicate 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_not_predicate 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").to_sql.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_same david.projects, david.projects + end + + test "proxy object can be stubbed" do + david = developers(:david) + david.projects.define_singleton_method(:extra_method) { 42 } + + assert_equal 42, david.projects.extra_method + 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 + + test "first! works on loaded associations" do + david = authors(:david) + assert_equal david.first_posts.first, david.first_posts.reload.first! + assert_predicate david.first_posts, :loaded? + assert_no_queries { david.first_posts.first! } + end + + def test_pluck_uses_loaded_target + david = authors(:david) + assert_equal david.first_posts.pluck(:title), david.first_posts.load.pluck(:title) + assert_predicate david.first_posts, :loaded? + assert_no_queries { david.first_posts.pluck(:title) } + end + + def test_reset_unloads_target + david = authors(:david) + david.posts.reload + + assert_predicate david.posts, :loaded? + david.posts.reset + assert_not_predicate 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 + + module MyModule + def comments; :none end + end + + class MyArticle < ActiveRecord::Base + self.table_name = "articles" + include MyModule + has_many :comments, inverse_of: false + end + + def test_included_module_overwrites_association_methods + assert_equal :none, MyArticle.new.comments + 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..42eca233ce --- /dev/null +++ b/activerecord/test/cases/attribute_decorators_test.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +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 cast(value) + "#{super} #{@decoration}" + end + + alias deserialize cast + 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.drop_table "attribute_decorators_model", if_exists: true + 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 + Model.reset_column_information + 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, :string, 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, :string, 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 cast(value) + return if value.nil? + value * 2 + end + alias deserialize cast + end + + test "decorating with a proc" do + Model.attribute :an_int, :integer + 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..54512068ee --- /dev/null +++ b/activerecord/test/cases/attribute_methods/read_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module AttributeMethods + class ReadTest < ActiveRecord::TestCase + FakeColumn = Struct.new(:name) do + def type; :integer; end + end + + def setup + @klass = Class.new(Class.new { def self.initialize_generated_modules; end }) do + def self.superclass; Base; end + def self.base_class?; true; end + def self.decorate_matching_attribute_types(*); end + + include ActiveRecord::DefineCallbacks + include ActiveRecord::AttributeMethods + + def self.attribute_names + %w{ one two three } + end + + def self.primary_key + end + + def self.columns + attribute_names.map { FakeColumn.new(name) } + end + + def self.columns_hash + Hash[attribute_names.map { |name| + [name, FakeColumn.new(name)] + }] + end + end + end + + def test_define_attribute_methods + instance = @klass.new + + @klass.attribute_names.each do |name| + assert_not_includes instance.methods.map(&:to_s), name + end + + @klass.define_attribute_methods + + @klass.attribute_names.each do |name| + assert_includes instance.methods.map(&:to_s), 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..d341dd0083 --- /dev/null +++ b/activerecord/test/cases/attribute_methods_test.rb @@ -0,0 +1,1046 @@ +# frozen_string_literal: true + +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 + + test "attribute_for_inspect with a string" do + t = topics(:first) + t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters" + + assert_equal '"The First Topic Now Has A Title With\nNewlines And ..."', t.attribute_for_inspect(:title) + end + + test "attribute_for_inspect with a date" do + t = topics(:first) + + assert_equal %("#{t.written_on.to_s(:db)}"), t.attribute_for_inspect(:written_on) + end + + test "attribute_for_inspect with an array" do + t = topics(:first) + t.content = [Object.new] + + assert_match %r(\[#<Object:0x[0-9a-f]+>\]), t.attribute_for_inspect(:content) + end + + test "attribute_for_inspect with a long array" do + t = topics(:first) + t.content = (1..11).to_a + + assert_equal "[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]", t.attribute_for_inspect(:content) + end + + test "attribute_for_inspect with a non-primary key id attribute" do + t = topics(:first).becomes(TitlePrimaryKeyTopic) + t.title = "The First Topic Now Has A Title With\nNewlines And More Than 50 Characters" + + assert_equal "1", t.attribute_for_inspect(:id) + end + + test "attribute_present" do + 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_not t.attribute_present?("content") + assert_not t.attribute_present?("author_name") + end + + test "attribute_present with booleans" do + 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_not b3.attribute_present?(:value) + + b4 = Boolean.new + b4.value = false + b4.save! + assert Boolean.find(b4.id).attribute_present?(:value) + end + + test "caching a nil primary key" do + klass = Class.new(Minimalistic) + assert_called(klass, :reset_primary_key, returns: nil) do + 2.times { klass.primary_key } + end + end + + test "attribute keys on a new instance" do + t = Topic.new + assert_nil t.title, "The topics table has a title column, so it should be nil" + assert_raise(NoMethodError) { t.title2 } + end + + test "boolean attributes" do + assert_not_predicate Topic.find(1), :approved? + assert_predicate Topic.find(2), :approved? + end + + test "set attributes" do + 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 + + test "set attributes without a hash" do + topic = Topic.new + assert_raise(ArgumentError) { topic.attributes = "" } + end + + test "integers as nil" do + test = AutoId.create(value: "") + assert_nil AutoId.find(test.id).value + end + + test "set attributes with a block" do + 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 + + test "respond_to?" do + 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_not_respond_to topic, "nothingness" + assert_not_respond_to topic, :nothingness + end + + test "respond_to? with a custom primary key" do + keyboard = Keyboard.create + assert_not_nil keyboard.key_number + assert_equal keyboard.key_number, keyboard.id + assert_respond_to keyboard, "key_number" + assert_respond_to keyboard, "id" + end + + test "id_before_type_cast with a custom primary key" do + keyboard = Keyboard.create + keyboard.key_number = "10" + assert_equal "10", keyboard.id_before_type_cast + assert_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 + + # IRB inspects the return value of MyModel.allocate. + test "allocated objects can be inspected" do + topic = Topic.allocate + assert_equal "#<Topic not initialized>", topic.inspect + end + + test "array content" do + content = %w( one two three ) + topic = Topic.new + topic.content = content + topic.save + + assert_equal content, Topic.find(topic.id).content + end + + test "read attributes_before_type_cast" do + 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?(:Mysql2Adapter) + test "read attributes_before_type_cast on a boolean" do + bool = Boolean.create!("value" => false) + assert_equal 0, bool.reload.attributes_before_type_cast["value"] + end + end + + test "read attributes_before_type_cast on a datetime" do + 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_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 + + test "read attributes_after_type_cast on a date" do + 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 + + test "hash content" do + 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 + + test "update array content" do + 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 + + test "case-sensitive attributes hash" do + # DB2 is not case-sensitive. + return true if current_adapter?(:DB2Adapter) + + assert_equal @loaded_fixtures["computers"]["workstation"].to_hash, Computer.first.attributes + end + + test "attributes without primary key" do + 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.has_attribute?("id") + end + + test "hashes are not mangled" do + 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 + + test "create through factory" do + topic = Topic.create(title: "New Topic") + topicReloaded = Topic.find(topic.id) + assert_equal(topic, topicReloaded) + end + + test "write_attribute" do + 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 + + test "write_attribute can write aliased attributes as well" do + topic = Topic.new(title: "Don't change the topic") + topic.write_attribute :heading, "New topic" + + assert_equal "New topic", topic.title + end + + test "write_attribute raises ActiveModel::MissingAttributeError when the attribute does not exist" do + topic = Topic.first + assert_raises(ActiveModel::MissingAttributeError) { topic.update_columns(no_column_exists: "Hello!") } + assert_raises(ActiveModel::UnknownAttributeError) { topic.update(no_column_exists: "Hello!") } + end + + test "write_attribute allows writing to aliased attributes" do + topic = Topic.first + assert_nothing_raised { topic.update_columns(heading: "Hello!") } + assert_nothing_raised { topic.update(heading: "Hello!") } + end + + test "read_attribute" do + 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 + + test "read_attribute can read aliased attributes as well" do + topic = Topic.new(title: "Don't change the topic") + + assert_equal "Don't change the topic", topic.read_attribute("heading") + assert_equal "Don't change the topic", topic["heading"] + + assert_equal "Don't change the topic", topic.read_attribute(:heading) + assert_equal "Don't change the topic", topic[:heading] + end + + test "read_attribute raises ActiveModel::MissingAttributeError when the attribute does not exist" do + 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 + + test "read_attribute when false" do + topic = topics(:first) + topic.approved = false + assert_not topic.approved?, "approved should be false" + topic.approved = "false" + assert_not topic.approved?, "approved should be false" + end + + test "read_attribute when true" do + topic = topics(:first) + topic.approved = true + assert topic.approved?, "approved should be true" + topic.approved = "true" + assert topic.approved?, "approved should be true" + end + + test "boolean attributes writing and reading" do + topic = Topic.new + topic.approved = "false" + assert_not topic.approved?, "approved should be false" + + topic.approved = "false" + assert_not 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 + + test "overridden write_attribute" do + 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 + + test "overridden read_attribute" do + 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 + + test "read overridden attribute" do + topic = Topic.new(title: "a") + def topic.title() "b" end + assert_equal "a", topic[:title] + end + + test "string attribute predicate" do + [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 + + test "number attribute predicate" do + [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 + + test "boolean attribute predicate" do + [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 + + test "custom field attribute predicate" do + 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_predicate object, :string_value? + + object.string_value = " " + assert_not_predicate object, :string_value? + + assert_equal 1, object.int_value.to_i + assert_predicate object, :int_value? + + object.int_value = "0" + assert_not_predicate object, :int_value? + end + + test "non-attribute read and write" do + topic = Topic.new + assert_not_respond_to topic, "mumbo" + assert_raise(NoMethodError) { topic.mumbo } + assert_raise(NoMethodError) { topic.mumbo = 5 } + end + + test "undeclared attribute method does not affect respond_to? and method_missing" do + topic = @target.new(title: "Budget") + assert_respond_to topic, "title" + assert_equal "Budget", topic.title + assert_not_respond_to topic, "title_hello_world" + assert_raise(NoMethodError) { topic.title_hello_world } + end + + test "declared prefixed attribute method affects respond_to? and method_missing" do + 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_respond_to topic, 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 + + test "declared suffixed attribute method affects respond_to? and method_missing" do + %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_respond_to topic, 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 + + test "declared affixed attribute method affects respond_to? and method_missing" do + [["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_respond_to topic, 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 + + test "should unserialize attributes for frozen records" do + myobj = { value1: :value2 } + topic = Topic.create(content: myobj) + topic.freeze + assert_equal myobj, topic.content + end + + test "typecast attribute from select to false" do + Topic.create(title: "Budget") + # Oracle does not support boolean expressions in SELECT. + if current_adapter?(:OracleAdapter, :FbAdapter) + 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_not_predicate topic, :is_test? + end + + test "typecast attribute from select to true" do + Topic.create(title: "Budget") + # Oracle does not support boolean expressions in SELECT. + if current_adapter?(:OracleAdapter, :FbAdapter) + 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_predicate topic, :is_test? + end + + test "raises ActiveRecord::DangerousAttributeError when defining an AR method in a model" do + %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 + + test "converted values are returned after assignment" do + 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 + assert_equal "1337", developer.name + end + + test "write nil to time attribute" do + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + record.written_on = nil + assert_nil record.written_on + end + end + + test "write time to date attribute" do + 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 + + test "time attributes are retrieved in the current time zone" do + 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 + + test "setting a time zone-aware attribute to UTC" do + 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 + + test "setting time zone-aware attribute in other time zone" do + 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 + + test "setting time zone-aware read attribute" do + 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 + + test "setting time zone-aware attribute with a string" do + 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 + + test "time zone-aware attribute saved" do + 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 + + test "setting a time zone-aware attribute to a blank string returns nil" do + 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 + + test "setting a time zone-aware attribute interprets time zone-unaware string in time zone" do + 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 + + test "setting a time zone-aware datetime in the current time zone" do + 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 + + test "YAML dumping a record with time zone-aware attribute" do + in_time_zone "Pacific Time (US & Canada)" do + record = Topic.new(id: 1) + record.written_on = "Jan 01 00:00:00 2014" + assert_equal record, YAML.load(YAML.dump(record)) + end + end + + test "setting a time zone-aware time in the current time zone" do + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + time_string = "10:00:00" + expected_time = Time.zone.parse("2000-01-01 #{time_string}") + + record.bonus_time = time_string + assert_equal expected_time, record.bonus_time + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.bonus_time.time_zone + + record.bonus_time = "" + assert_nil record.bonus_time + end + end + + test "setting a time zone-aware time with DST" do + in_time_zone "Pacific Time (US & Canada)" do + current_time = Time.zone.local(2014, 06, 15, 10) + record = @target.new(bonus_time: current_time) + time_before_save = record.bonus_time + + record.save + record.reload + + assert_equal time_before_save, record.bonus_time + assert_equal ActiveSupport::TimeZone["Pacific Time (US & Canada)"], record.bonus_time.time_zone + end + end + + test "setting invalid string to a zone-aware time attribute" do + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new + time_string = "ABC" + + record.bonus_time = time_string + assert_nil record.bonus_time + end + end + + test "removing time zone-aware types" do + with_time_zone_aware_types(:datetime) do + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new(bonus_time: "10:00:00") + expected_time = Time.utc(2000, 01, 01, 10) + + assert_equal expected_time, record.bonus_time + assert_predicate record.bonus_time, :utc? + end + end + end + + test "time zone-aware attributes do not recurse infinitely on invalid values" do + in_time_zone "Pacific Time (US & Canada)" do + record = @target.new(bonus_time: []) + assert_nil record.bonus_time + end + end + + test "setting a time_zone_conversion_for_attributes should write the value on a class variable" do + 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 + + test "attribute readers respect access control" do + privatize("title") + + topic = @target.new(title: "The pros and cons of programming naked.") + assert_not_respond_to topic, :title + exception = assert_raise(NoMethodError) { topic.title } + assert_includes exception.message, "private method" + assert_equal "I'm private", topic.send(:title) + end + + test "attribute writers respect access control" do + privatize("title=(value)") + + topic = @target.new + assert_not_respond_to topic, :title= + exception = assert_raise(NoMethodError) { topic.title = "Pants" } + assert_includes exception.message, "private method" + topic.send(:title=, "Very large pants") + end + + test "attribute predicates respect access control" do + privatize("title?") + + topic = @target.new(title: "Isaac Newton's pants") + assert_not_respond_to topic, :title? + exception = assert_raise(NoMethodError) { topic.title? } + assert_includes exception.message, "private method" + assert topic.send(:title?) + end + + test "bulk updates respect access control" do + 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 + + test "bulk update raises ActiveRecord::UnknownAttributeError" do + error = assert_raises(ActiveRecord::UnknownAttributeError) { + Topic.new(hello: "world") + } + assert_instance_of Topic, error.record + assert_equal "hello", error.attribute + assert_equal "unknown attribute 'hello' for Topic.", error.message + end + + test "method overrides in multi-level subclasses" do + 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 + + test "global methods are overwritten" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "computers" + end + + assert_not klass.instance_method_already_implemented?(:system) + computer = klass.new + assert_nil computer.system + end + + test "global methods are overwritten when subclassing" do + klass = Class.new(ActiveRecord::Base) do + self.abstract_class = true + end + + subklass = Class.new(klass) do + self.table_name = "computers" + end + + assert_not klass.instance_method_already_implemented?(:system) + assert_not subklass.instance_method_already_implemented?(:system) + computer = subklass.new + assert_nil computer.system + end + + test "instance methods should be defined on the base class" do + 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 + + test "define_attribute_method works with both symbol and string" do + klass = Class.new(ActiveRecord::Base) + + assert_nothing_raised { klass.define_attribute_method(:foo) } + assert_nothing_raised { klass.define_attribute_method("bar") } + end + + test "read_attribute with nil should not asplode" do + assert_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].) + test "inherited custom accessors" do + 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 + + test "inherited custom accessors with reserved names" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "computers" + self.abstract_class = true + def system; "omg"; end + def system=(val); self.developer = val; end + end + + subklass = Class.new(klass) + [klass, subklass].each(&:define_attribute_methods) + + computer = subklass.find(1) + assert_equal "omg", computer.system + + computer.developer = 99 + assert_equal 99, computer.developer + end + + test "on_the_fly_super_invokable_generated_attribute_methods_via_method_missing" do + 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 + + test "on-the-fly super-invokable generated attribute predicates via method_missing" do + 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 + + test "calling super when the parent does not define method raises NoMethodError" do + 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 + + test "attribute_method?" do + assert @target.attribute_method?(:title) + assert @target.attribute_method?(:title=) + assert_not @target.attribute_method?(:wibble) + end + + test "attribute_method? returns false if the table does not exist" do + @target.table_name = "wibble" + assert_not @target.attribute_method?(:title) + end + + test "attribute_names on a new record" do + model = @target.new + + assert_equal @target.column_names, model.attribute_names + end + + test "attribute_names on a queried record" do + model = @target.last! + + assert_equal @target.column_names, model.attribute_names + end + + test "attribute_names with a custom select" do + 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 + + test "came_from_user?" do + model = @target.first + + assert_not_predicate model, :id_came_from_user? + model.id = "omg" + assert_predicate model, :id_came_from_user? + end + + test "accessed_fields" do + model = @target.first + + assert_equal [], model.accessed_fields + + model.title + + assert_equal ["title"], model.accessed_fields + end + + test "generated attribute methods ancestors have correct class" do + mod = Topic.send(:generated_attribute_methods) + assert_match %r(GeneratedAttributeMethods), mod.inspect + 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.send(:generated_attribute_methods).instance_methods(false) + klass + end + + def with_time_zone_aware_types(*types) + old_types = ActiveRecord::Base.time_zone_aware_types + ActiveRecord::Base.time_zone_aware_types = types + yield + ensure + ActiveRecord::Base.time_zone_aware_types = old_types + 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/attributes_test.rb b/activerecord/test/cases/attributes_test.rb new file mode 100644 index 0000000000..2632aec7ab --- /dev/null +++ b/activerecord/test/cases/attributes_test.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require "cases/helper" + +class OverloadedType < ActiveRecord::Base + attribute :overloaded_float, :integer + attribute :overloaded_string_with_limit, :string, limit: 50 + attribute :non_existent_decimal, :decimal + attribute :string_with_default, :string, default: "the overloaded default" +end + +class ChildOfOverloadedType < OverloadedType +end + +class GrandchildOfOverloadedType < ChildOfOverloadedType + attribute :overloaded_float, :float +end + +class UnoverloadedType < ActiveRecord::Base + self.table_name = "overloaded_types" +end + +module ActiveRecord + class CustomPropertiesTest < ActiveRecord::TestCase + test "overloading types" do + 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 + + test "overloaded properties save" do + data = OverloadedType.new + + data.overloaded_float = "2.2" + data.save! + data.reload + + assert_equal 2, data.overloaded_float + assert_kind_of Integer, OverloadedType.last.overloaded_float + assert_equal 2.0, UnoverloadedType.last.overloaded_float + assert_kind_of Float, UnoverloadedType.last.overloaded_float + end + + test "properties assigned in constructor" do + data = OverloadedType.new(overloaded_float: "3.3") + + assert_equal 3, data.overloaded_float + end + + test "overloaded properties with limit" do + assert_equal 50, OverloadedType.type_for_attribute("overloaded_string_with_limit").limit + assert_equal 255, UnoverloadedType.type_for_attribute("overloaded_string_with_limit").limit + end + + test "nonexistent attribute" do + data = OverloadedType.new(non_existent_decimal: 1) + + assert_equal BigDecimal(1), data.non_existent_decimal + assert_raise ActiveRecord::UnknownAttributeError do + UnoverloadedType.new(non_existent_decimal: 1) + end + end + + test "model with nonexistent attribute with default value can be saved" do + klass = Class.new(OverloadedType) do + attribute :non_existent_string_with_default, :string, default: "nonexistent" + end + + model = klass.new + assert model.save + end + + test "changing defaults" do + 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 + + test "defaults are not touched on the columns" do + assert_equal "the original default", OverloadedType.columns_hash["string_with_default"].default + end + + test "children inherit custom properties" do + data = ChildOfOverloadedType.new(overloaded_float: "4.4") + + assert_equal 4, data.overloaded_float + end + + test "children can override parents" do + data = GrandchildOfOverloadedType.new(overloaded_float: "4.4") + + assert_equal 4.4, data.overloaded_float + end + + test "overloading properties does not attribute method order" do + attribute_names = OverloadedType.attribute_names + assert_equal %w(id overloaded_float unoverloaded_float overloaded_string_with_limit string_with_default non_existent_decimal), attribute_names + end + + test "caches are cleared" do + klass = Class.new(OverloadedType) + + assert_equal 6, klass.attribute_types.length + assert_equal 6, klass.column_defaults.length + assert_equal 6, klass.attribute_names.length + assert_not klass.attribute_types.include?("wibble") + + klass.attribute :wibble, Type::Value.new + + assert_equal 7, klass.attribute_types.length + assert_equal 7, klass.column_defaults.length + assert_equal 7, klass.attribute_names.length + assert_includes klass.attribute_types, "wibble" + end + + test "the given default value is cast from user" do + custom_type = Class.new(Type::Value) do + def cast(*) + "from user" + end + + def deserialize(*) + "from database" + end + end + + klass = Class.new(OverloadedType) do + attribute :wibble, custom_type.new, default: "default" + end + model = klass.new + + assert_equal "from user", model.wibble + end + + test "procs for default values" do + klass = Class.new(OverloadedType) do + @@counter = 0 + attribute :counter, :integer, default: -> { @@counter += 1 } + end + + assert_equal 1, klass.new.counter + assert_equal 2, klass.new.counter + end + + test "procs for default values are evaluated even after column_defaults is called" do + klass = Class.new(OverloadedType) do + @@counter = 0 + attribute :counter, :integer, default: -> { @@counter += 1 } + end + + assert_equal 1, klass.new.counter + + # column_defaults will increment the counter since the proc is called + klass.column_defaults + + assert_equal 3, klass.new.counter + end + + test "procs are memoized before type casting" do + klass = Class.new(OverloadedType) do + @@counter = 0 + attribute :counter, :integer, default: -> { @@counter += 1 } + end + + model = klass.new + assert_equal 1, model.counter_before_type_cast + assert_equal 1, model.counter_before_type_cast + end + + test "user provided defaults are persisted even if unchanged" do + model = OverloadedType.create! + + assert_equal "the overloaded default", model.reload.string_with_default + end + + if current_adapter?(:PostgreSQLAdapter) + test "array types can be specified" do + klass = Class.new(OverloadedType) do + attribute :my_array, :string, limit: 50, array: true + attribute :my_int_array, :integer, array: true + end + + string_array = ConnectionAdapters::PostgreSQL::OID::Array.new( + Type::String.new(limit: 50)) + int_array = ConnectionAdapters::PostgreSQL::OID::Array.new( + Type::Integer.new) + assert_not_equal string_array, int_array + assert_equal string_array, klass.type_for_attribute("my_array") + assert_equal int_array, klass.type_for_attribute("my_int_array") + end + + test "range types can be specified" do + klass = Class.new(OverloadedType) do + attribute :my_range, :string, limit: 50, range: true + attribute :my_int_range, :integer, range: true + end + + string_range = ConnectionAdapters::PostgreSQL::OID::Range.new( + Type::String.new(limit: 50)) + int_range = ConnectionAdapters::PostgreSQL::OID::Range.new( + Type::Integer.new) + assert_not_equal string_range, int_range + assert_equal string_range, klass.type_for_attribute("my_range") + assert_equal int_range, klass.type_for_attribute("my_int_range") + end + end + + test "attributes added after subclasses load are inherited" do + parent = Class.new(ActiveRecord::Base) do + self.table_name = "topics" + end + + child = Class.new(parent) + child.new # => force a schema load + + parent.attribute(:foo, Type::Value.new) + + assert_equal(:bar, child.new(foo: :bar).foo) + end + + test "attributes not backed by database columns are not dirty when unchanged" do + assert_not_predicate OverloadedType.new, :non_existent_decimal_changed? + end + + test "attributes not backed by database columns are always initialized" do + OverloadedType.create! + model = OverloadedType.first + + assert_nil model.non_existent_decimal + model.non_existent_decimal = "123" + assert_equal 123, model.non_existent_decimal + end + + test "attributes not backed by database columns return the default on models loaded from database" do + child = Class.new(OverloadedType) do + attribute :non_existent_decimal, :decimal, default: 123 + end + child.create! + model = child.first + + assert_equal 123, model.non_existent_decimal + end + + test "attributes not backed by database columns properly interact with mutation and dirty" do + child = Class.new(ActiveRecord::Base) do + self.table_name = "topics" + attribute :foo, :string, default: "lol" + end + child.create! + model = child.first + + assert_equal "lol", model.foo + + model.foo << "asdf" + assert_equal "lolasdf", model.foo + assert_predicate model, :foo_changed? + + model.reload + assert_equal "lol", model.foo + + model.foo = "lol" + assert_not_predicate model, :changed? + end + + test "attributes not backed by database columns appear in inspect" do + inspection = OverloadedType.new.inspect + + assert_includes inspection, "non_existent_decimal" + end + + test "attributes do not require a type" do + klass = Class.new(OverloadedType) do + attribute :no_type + end + assert_equal 1, klass.new(no_type: 1).no_type + assert_equal "foo", klass.new(no_type: "foo").no_type + 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..88df0eed55 --- /dev/null +++ b/activerecord/test/cases/autosave_association_test.rb @@ -0,0 +1,1824 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/bird" +require "models/post" +require "models/comment" +require "models/company" +require "models/contract" +require "models/customer" +require "models/developer" +require "models/computer" +require "models/invoice" +require "models/line_item" +require "models/order" +require "models/parrot" +require "models/pirate" +require "models/project" +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" +require "models/member" +require "models/member_detail" +require "models/organization" +require "models/guitar" +require "models/tuning_peg" +require "models/reply" + +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 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, anonymous_class: person + } + + u = person.create!(first_name: "cool") + u.update!(first_name: "nah") # still valid because validation only applies on 'create' + assert_predicate 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 + + def test_cyclic_autosaves_do_not_add_multiple_validations + ship = ShipWithoutNestedAttributes.new + ship.prisoners.build + + assert_not_predicate ship, :valid? + assert_equal 1, ship.errors[:name].length + 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_predicate firm, :valid? + + firm.build_account_using_primary_key + assert_not_predicate firm.build_account_using_primary_key, :valid? + + assert firm.save + assert_not_predicate firm.account_using_primary_key, :persisted? + end + + def test_save_fails_for_invalid_has_one + firm = Firm.first + assert_predicate firm, :valid? + + firm.build_account + + assert_not_predicate firm.account, :valid? + assert_not_predicate firm, :valid? + assert_not 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_predicate firm, :valid? + + firm.build_unvalidated_account + + assert_not_predicate firm.unvalidated_account, :valid? + assert_predicate 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_not_predicate account, :persisted? + assert firm.save + assert_equal account, firm.account + assert_predicate 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_not_predicate account, :persisted? + assert firm.save + assert_equal account, firm.account + assert_predicate account, :persisted? + end + + def test_assignment_before_parent_saved + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = a = Account.find(1) + assert_not_predicate firm, :persisted? + assert_equal a, firm.account + assert firm.save + assert_equal a, firm.account + firm.association(:account).reload + assert_equal a, firm.account + end + + def test_assignment_before_either_saved + firm = Firm.new("name" => "GlobalMegaCorp") + firm.account = a = Account.new("credit_limit" => 1000) + assert_not_predicate firm, :persisted? + assert_not_predicate a, :persisted? + assert_equal a, firm.account + assert firm.save + assert_predicate firm, :persisted? + assert_predicate a, :persisted? + assert_equal a, firm.account + firm.association(:account).reload + assert_equal a, firm.account + 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_predicate client, :valid? + + client.build_firm + assert_not_predicate client.firm, :valid? + + assert client.save + assert_not_predicate 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_not_predicate log.developer, :valid? + assert_not_predicate log, :valid? + assert_not 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_not_predicate log.unvalidated_developer, :valid? + assert_predicate 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_not_predicate apple, :persisted? + assert client.save + assert apple.save + assert_predicate apple, :persisted? + assert_equal apple, client.firm + client.association(:firm).reload + assert_equal apple, client.firm + end + + def test_assignment_before_either_saved + final_cut = Client.new("name" => "Final Cut") + apple = Firm.new("name" => "Apple") + final_cut.firm = apple + assert_not_predicate final_cut, :persisted? + assert_not_predicate apple, :persisted? + assert final_cut.save + assert_predicate final_cut, :persisted? + assert_predicate apple, :persisted? + assert_equal apple, final_cut.firm + final_cut.association(:firm).reload + assert_equal apple, final_cut.firm + 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_predicate 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_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not molecule.persisted?, "Molecule should not be persisted when its electrons are invalid" + end + + def test_errors_should_be_indexed_when_passed_as_array + guitar = Guitar.new + tuning_peg_valid = TuningPeg.new + tuning_peg_valid.pitch = 440.0 + tuning_peg_invalid = TuningPeg.new + + guitar.tuning_pegs = [tuning_peg_valid, tuning_peg_invalid] + + assert_not_predicate tuning_peg_invalid, :valid? + assert_predicate tuning_peg_valid, :valid? + assert_not_predicate guitar, :valid? + assert_equal ["is not a number"], guitar.errors["tuning_pegs[1].pitch"] + assert_not_equal ["is not a number"], guitar.errors["tuning_pegs.pitch"] + end + + def test_errors_should_be_indexed_when_global_flag_is_set + old_attribute_config = ActiveRecord::Base.index_nested_attribute_errors + ActiveRecord::Base.index_nested_attribute_errors = true + + molecule = Molecule.new + valid_electron = Electron.new(name: "electron") + invalid_electron = Electron.new + + molecule.electrons = [valid_electron, invalid_electron] + + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not_predicate molecule, :valid? + assert_equal ["can't be blank"], molecule.errors["electrons[1].name"] + assert_not_equal ["can't be blank"], molecule.errors["electrons.name"] + ensure + ActiveRecord::Base.index_nested_attribute_errors = old_attribute_config + end + + def test_errors_details_should_be_set + molecule = Molecule.new + valid_electron = Electron.new(name: "electron") + invalid_electron = Electron.new + + molecule.electrons = [valid_electron, invalid_electron] + + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not_predicate molecule, :valid? + assert_equal [{ error: :blank }], molecule.errors.details[:"electrons.name"] + end + + def test_errors_details_should_be_indexed_when_passed_as_array + guitar = Guitar.new + tuning_peg_valid = TuningPeg.new + tuning_peg_valid.pitch = 440.0 + tuning_peg_invalid = TuningPeg.new + + guitar.tuning_pegs = [tuning_peg_valid, tuning_peg_invalid] + + assert_not_predicate tuning_peg_invalid, :valid? + assert_predicate tuning_peg_valid, :valid? + assert_not_predicate guitar, :valid? + assert_equal [{ error: :not_a_number, value: nil }], guitar.errors.details[:"tuning_pegs[1].pitch"] + assert_equal [], guitar.errors.details[:"tuning_pegs.pitch"] + end + + def test_errors_details_should_be_indexed_when_global_flag_is_set + old_attribute_config = ActiveRecord::Base.index_nested_attribute_errors + ActiveRecord::Base.index_nested_attribute_errors = true + + molecule = Molecule.new + valid_electron = Electron.new(name: "electron") + invalid_electron = Electron.new + + molecule.electrons = [valid_electron, invalid_electron] + + assert_not_predicate invalid_electron, :valid? + assert_predicate valid_electron, :valid? + assert_not_predicate molecule, :valid? + assert_equal [{ error: :blank }], molecule.errors.details[:"electrons[1].name"] + assert_equal [], molecule.errors.details[:"electrons.name"] + ensure + ActiveRecord::Base.index_nested_attribute_errors = old_attribute_config + end + + def test_valid_adding_with_nested_attributes + molecule = Molecule.new + valid_electron = Electron.new(name: "electron") + + molecule.electrons = [valid_electron] + molecule.save + + assert_predicate valid_electron, :valid? + assert_predicate molecule, :persisted? + assert_equal 1, molecule.electrons.count + end +end + +class TestDefaultAutosaveAssociationOnAHasManyAssociation < ActiveRecord::TestCase + fixtures :companies, :developers + + def test_invalid_adding + firm = Firm.find(1) + assert_not (firm.clients_of_firm << c = Client.new) + assert_not_predicate c, :persisted? + assert_not_predicate firm, :valid? + assert_not firm.save + assert_not_predicate 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_not_predicate c, :persisted? + assert_not_predicate c, :valid? + assert_not_predicate new_firm, :valid? + assert_not new_firm.save + assert_not_predicate c, :persisted? + assert_not_predicate new_firm, :persisted? + end + + def test_adding_unsavable_association + new_firm = Firm.new("name" => "A New Firm, Inc") + client = new_firm.clients.new("name" => "Apple") + client.throw_on_save = true + + assert_predicate client, :valid? + assert_predicate new_firm, :valid? + assert_not new_firm.save + assert_not_predicate new_firm, :persisted? + assert_not_predicate client, :persisted? + end + + def test_invalid_adding_with_validate_false + firm = Firm.first + client = Client.new + firm.unvalidated_clients_of_firm << client + + assert_predicate firm, :valid? + assert_not_predicate client, :valid? + assert firm.save + assert_not_predicate client, :persisted? + end + + def test_valid_adding_with_validate_false + no_of_clients = Client.count + + firm = Firm.first + client = Client.new("name" => "Apple") + + assert_predicate firm, :valid? + assert_predicate client, :valid? + assert_not_predicate client, :persisted? + + firm.unvalidated_clients_of_firm << client + + assert firm.save + assert_predicate client, :persisted? + assert_equal no_of_clients + 1, Client.count + end + + def test_parent_should_save_children_record_with_foreign_key_validation_set_in_before_save_callback + company = NewlyContractedCompany.new(name: "test") + + assert company.save + assert_not_empty company.reload.new_contracts + end + + def test_parent_should_not_get_saved_with_duplicate_children_records + assert_no_difference "Reply.count" do + assert_no_difference "SillyUniqueReply.count" do + reply = Reply.new + reply.silly_unique_replies.build([ + { content: "Best content" }, + { content: "Best content" } + ]) + + assert_not reply.save + assert_equal ["is invalid"], reply.errors[:silly_unique_replies] + assert_empty reply.silly_unique_replies.first.errors + + assert_equal( + ["has already been taken"], + reply.silly_unique_replies.last.errors[:content] + ) + end + end + end + + def test_invalid_build + new_client = companies(:first_firm).clients_of_firm.build + assert_not_predicate new_client, :persisted? + assert_not_predicate new_client, :valid? + assert_equal new_client, companies(:first_firm).clients_of_firm.last + assert_not companies(:first_firm).save + assert_not_predicate new_client, :persisted? + assert_equal 2, companies(:first_firm).clients_of_firm.reload.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_predicate new_firm, :persisted? + assert_predicate 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.reload.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_includes firm.clients, companies(:second_client) + end + + def test_assign_ids_for_through_a_belongs_to + firm = Firm.new("name" => "Apple") + firm.developer_ids = [developers(:david).id, developers(:jamis).id] + firm.save + firm.reload + assert_equal 2, firm.developers.length + assert_includes firm.developers, developers(:david) + end + + def test_build_before_save + company = companies(:first_firm) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build("name" => "Another Client") } + assert_not_predicate company.clients_of_firm, :loaded? + + company.name += "-changed" + assert_queries(2) { assert company.save } + assert_predicate new_client, :persisted? + assert_equal 3, company.clients_of_firm.reload.size + end + + def test_build_many_before_save + company = companies(:first_firm) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + 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.reload.size + end + + def test_build_via_block_before_save + company = companies(:first_firm) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + new_client = assert_no_queries { company.clients_of_firm.build { |client| client.name = "Another Client" } } + assert_not_predicate company.clients_of_firm, :loaded? + + company.name += "-changed" + assert_queries(2) { assert company.save } + assert_predicate new_client, :persisted? + assert_equal 3, company.clients_of_firm.reload.size + end + + def test_build_many_via_block_before_save + company = companies(:first_firm) + + # Load schema information so we don't query below if running just this test. + Client.define_attribute_methods + + 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.reload.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_includes firm.clients, 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_not_predicate new_firm, :persisted? + new_account.firm = new_firm + new_account.save! + + assert_predicate new_firm, :persisted? + + new_account = Account.new("credit_limit" => 1000) + new_autosaved_firm = Firm.new("name" => "some firm") + + assert_not_predicate new_autosaved_firm, :persisted? + new_account.unautosaved_firm = new_autosaved_firm + new_account.save! + + assert_not_predicate 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_not_predicate account, :persisted? + firm.account = account + firm.save! + + assert_predicate account, :persisted? + + firm = Firm.new("name" => "some firm") + account = Account.new("credit_limit" => 1000) + + firm.unautosaved_account = account + + assert_not_predicate account, :persisted? + firm.unautosaved_account = account + firm.save! + + assert_not_predicate 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_not_predicate account, :persisted? + firm.accounts << account + + firm.save! + assert_predicate account, :persisted? + + firm = Firm.new("name" => "some firm") + account = Account.new("credit_limit" => 1000) + + assert_not_predicate account, :persisted? + firm.unautosaved_accounts << account + + firm.save! + assert_not_predicate account, :persisted? + end + + def test_autosave_new_record_with_after_create_callback + post = PostWithAfterCreateCallback.new(title: "Captain Murphy", body: "is back") + post.comments.build(body: "foo") + post.save! + + assert_not_nil post.author_id + end +end + +class TestDestroyAsPartOfAutosaveAssociation < ActiveRecord::TestCase + self.use_transactional_tests = 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 tests 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_not_predicate @pirate.reload, :marked_for_destruction? + assert_not_predicate @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_destruction + assert_not_predicate @pirate.ship, :marked_for_destruction? + + @pirate.ship.mark_for_destruction + id = @pirate.ship.id + + assert_predicate @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_not_predicate @pirate, :valid? + + @pirate.ship.mark_for_destruction + assert_not_called(@pirate.ship, :valid?) do + assert_difference("Ship.count", -1) { @pirate.save! } + end + 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" + @ship.name_will_change! + + assert_raise(RuntimeError) { assert_not @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 + assert_not_called(@pirate.ship, :save) do + assert @pirate.save + end + end + + # belongs_to + def test_should_destroy_a_parent_association_as_part_of_the_save_transaction_if_it_was_marked_for_destruction + assert_not_predicate @ship.pirate, :marked_for_destruction? + + @ship.pirate.mark_for_destruction + id = @ship.pirate.id + + assert_predicate @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_not_predicate @ship, :valid? + + @ship.pirate.mark_for_destruction + assert_not_called(@ship.pirate, :valid?) do + assert_difference("Pirate.count", -1) { @ship.save! } + end + 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_not @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_not @pirate.birds.any?(&:marked_for_destruction?) + + @pirate.birds.each(&:mark_for_destruction) + klass = @pirate.birds.first.class + ids = @pirate.birds.map(&:id) + + assert @pirate.birds.all?(&:marked_for_destruction?) + ids.each { |id| assert klass.find_by_id(id) } + + @pirate.save + assert_empty @pirate.reload.birds + 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_empty @pirate.reload.birds + 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_not_predicate @pirate, :valid? + + @pirate.birds.each(&:mark_for_destruction) + + assert_not_called(@pirate.birds.first, :valid?) do + assert_not_called(@pirate.birds.last, :valid?) do + assert_difference("Bird.count", -2) { @pirate.save! } + end + end + end + + def test_should_skip_validation_on_has_many_if_destroyed + @pirate.birds.create!(name: "birds_1") + + @pirate.birds.each { |bird| bird.name = "" } + assert_not_predicate @pirate, :valid? + + @pirate.birds.each(&:destroy) + assert_predicate @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(&:mark_for_destruction) + assert @pirate.save + + @pirate.birds.each do |bird| + assert_not_called(bird, :destroy) do + assert @pirate.save + end + end + 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_not @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(&: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_not @pirate.parrots.any?(&:marked_for_destruction?) + @pirate.parrots.each(&:mark_for_destruction) + + assert_no_difference "Parrot.count" do + @pirate.save + end + + assert_empty @pirate.reload.parrots + + join_records = Pirate.connection.select_all("SELECT * FROM parrots_pirates WHERE pirate_id = #{@pirate.id}") + assert_empty join_records + 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_not_predicate @pirate, :valid? + + @pirate.parrots.each { |parrot| parrot.mark_for_destruction } + + assert_not_called(@pirate.parrots.first, :valid?) do + assert_not_called(@pirate.parrots.last, :valid?) do + @pirate.save! + end + end + + assert_empty @pirate.reload.parrots + end + + def test_should_skip_validation_on_habtm_if_destroyed + @pirate.parrots.create!(name: "parrots_1") + + @pirate.parrots.each { |parrot| parrot.name = "" } + assert_not_predicate @pirate, :valid? + + @pirate.parrots.each(&:destroy) + assert_predicate @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(&:mark_for_destruction) + assert @pirate.save + + Pirate.transaction do + assert_no_queries 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_not @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(&: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_tests = 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_changed_for_autosave_should_handle_cycles + @ship.pirate = @pirate + assert_no_queries { @ship.save! } + + @parrot = @pirate.parrots.create(name: "some_name") + @parrot.name = "changed_name" + assert_queries(1) { @ship.save! } + assert_no_queries { @ship.save! } + 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_predicate @pirate, :invalid? + assert_predicate @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_predicate @pirate, :invalid? + assert_predicate @pirate.errors[:"ship.name"], :any? + assert_predicate @pirate.errors[:catchphrase], :any? + end + + def test_should_not_ignore_different_error_messages_on_the_same_attribute + old_validators = Ship._validators.deep_dup + old_callbacks = Ship._validate_callbacks.deep_dup + Ship.validates_format_of :name, with: /\w/ + @pirate.ship.name = "" + @pirate.catchphrase = nil + assert_predicate @pirate, :invalid? + assert_equal ["can't be blank", "is invalid"], @pirate.errors[:"ship.name"] + ensure + Ship._validators = old_validators if old_validators + Ship._validate_callbacks = old_callbacks if old_callbacks + 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_not 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_not @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 + + def test_mark_for_destruction_is_ignored_without_autosave_true + ship = ShipWithoutNestedAttributes.new(name: "The Black Flag") + ship.parts.build.mark_for_destruction + + assert_not_predicate ship, :valid? + end +end + +class TestAutosaveAssociationOnAHasOneThroughAssociation < ActiveRecord::TestCase + self.use_transactional_tests = false unless supports_savepoints? + + def setup + super + organization = Organization.create + @member = Member.create + MemberDetail.create(organization: organization, member: @member) + end + + def test_should_not_has_one_through_model + class << @member.organization + def save(*args) + super + raise "Oh noes!" + end + end + assert_nothing_raised { @member.save } + end +end + +class TestAutosaveAssociationOnABelongsToAssociation < ActiveRecord::TestCase + self.use_transactional_tests = 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_predicate @ship, :invalid? + assert_predicate @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_predicate @ship, :invalid? + assert_predicate @ship.errors[:name], :any? + assert_predicate @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_not 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_not @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.sort, @pirate.reload.send(@association_name).map(&:name).sort + 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.sort, @pirate.reload.send(@association_name).map(&:name).sort + end + + def test_should_update_children_when_autosave_is_true_and_parent_is_new_but_child_is_not + parrot = Parrot.create!(name: "Polly") + parrot.name = "Squawky" + pirate = Pirate.new(parrots: [parrot], catchphrase: "Arrrr") + + pirate.save! + + assert_equal "Squawky", parrot.reload.name + end + + def test_should_not_update_children_when_parent_creation_with_no_reason + parrot = Parrot.create!(name: "Polly") + assert_equal 0, parrot.updated_count + + Pirate.create!(parrot_ids: [parrot.id], catchphrase: "Arrrr") + assert_equal 0, parrot.reload.updated_count + end + + def test_should_automatically_validate_the_associated_models + @pirate.send(@association_name).each { |child| child.name = "" } + + assert_not_predicate @pirate, :valid? + assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] + assert_empty @pirate.errors[@association_name] + end + + def test_should_not_use_default_invalid_error_on_associated_models + @pirate.send(@association_name).build(name: "") + + assert_not_predicate @pirate, :valid? + assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] + assert_empty @pirate.errors[@association_name] + 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_not_predicate @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_empty @pirate.errors[@association_name] + 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_not_predicate @pirate, :valid? + assert_equal ["can't be blank"], @pirate.errors["#{@association_name}.name"] + assert_predicate @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_not @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_not 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_not @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_tests = 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_tests = 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_tests = 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_tests = 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_predicate @pirate, :valid? + @pirate.birds.each { |bird| bird.name = "" } + + assert_not_predicate @pirate, :valid? + end +end + +class TestAutosaveAssociationValidationsOnAHasOneAssociation < ActiveRecord::TestCase + self.use_transactional_tests = 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_predicate @pirate, :valid? + @pirate.ship.name = "" + assert_not_predicate @pirate, :valid? + end + + test "should not automatically add validate associations without :validate => true" do + assert_predicate @pirate, :valid? + @pirate.non_validated_ship.name = "" + assert_predicate @pirate, :valid? + end +end + +class TestAutosaveAssociationValidationsOnABelongsToAssociation < ActiveRecord::TestCase + self.use_transactional_tests = 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_predicate @pirate, :valid? + @pirate.parrot = Parrot.new(name: "") + assert_not_predicate @pirate, :valid? + end + + test "should not automatically validate associations without :validate => true" do + assert_predicate @pirate, :valid? + @pirate.non_validated_parrot = Parrot.new(name: "") + assert_predicate @pirate, :valid? + end +end + +class TestAutosaveAssociationValidationsOnAHABTMAssociation < ActiveRecord::TestCase + self.use_transactional_tests = 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_predicate @pirate, :valid? + @pirate.parrots = [ Parrot.new(name: "popuga") ] + @pirate.parrots.each { |parrot| parrot.name = "" } + assert_not_predicate @pirate, :valid? + end + + test "should not automatically validate associations without :validate => true" do + assert_predicate @pirate, :valid? + @pirate.non_validated_parrots = [ Parrot.new(name: "popuga") ] + @pirate.non_validated_parrots.each { |parrot| parrot.name = "" } + assert_predicate @pirate, :valid? + end +end + +class TestAutosaveAssociationValidationMethodsGeneration < ActiveRecord::TestCase + self.use_transactional_tests = 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_not_respond_to @pirate, :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_not_respond_to @pirate, :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 + +class TestAutosaveAssociationOnAHasManyAssociationWithInverse < ActiveRecord::TestCase + class Post < ActiveRecord::Base + has_many :comments, inverse_of: :post + end + + class Comment < ActiveRecord::Base + belongs_to :post, inverse_of: :comments + + attr_accessor :post_comments_count + after_save do + self.post_comments_count = post.comments.count + end + end + + def setup + Comment.delete_all + end + + def test_after_save_callback_with_autosave + post = Post.new(title: "Test", body: "...") + comment = post.comments.build(body: "...") + post.save! + + assert_equal 1, post.comments.count + assert_equal 1, comment.post_comments_count + end +end + +class TestAutosaveAssociationOnAHasManyAssociationDefinedInSubclassWithAcceptsNestedAttributes < ActiveRecord::TestCase + def test_should_update_children_when_asssociation_redefined_in_subclass + agency = Agency.create!(name: "Agency") + valid_project = Project.create!(firm: agency, name: "Initial") + agency.update!( + "projects_attributes" => { + "0" => { + "name" => "Updated", + "id" => valid_project.id + } + } + ) + valid_project.reload + + assert_equal "Updated", valid_project.name + end +end diff --git a/activerecord/test/cases/base_test.rb b/activerecord/test/cases/base_test.rb new file mode 100644 index 0000000000..4938b6865f --- /dev/null +++ b/activerecord/test/cases/base_test.rb @@ -0,0 +1,1551 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/author" +require "models/topic" +require "models/reply" +require "models/category" +require "models/categorization" +require "models/company" +require "models/customer" +require "models/developer" +require "models/computer" +require "models/project" +require "models/default" +require "models/auto_id" +require "models/column_name" +require "models/subscriber" +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 "concurrent/atomic/count_down_latch" + +class FirstAbstractClass < ActiveRecord::Base + self.abstract_class = true +end +class SecondAbstractClass < FirstAbstractClass + self.abstract_class = true +end +class Photo < SecondAbstractClass; 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 NonExistentTable < ActiveRecord::Base; end +class TestOracleDefault < ActiveRecord::Base; end + +class ReadonlyTitlePost < Post + attr_readonly :title +end + +class Weird < ActiveRecord::Base; 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, :author_addresses, :categorizations, :categories, :posts + + def test_column_names_are_escaped + conn = ActiveRecord::Base.connection + classname = conn.class.name[/[^:]*$/] + badchar = { + "SQLite3Adapter" => '"', + "Mysql2Adapter" => "`", + "PostgreSQLAdapter" => '"', + "OracleAdapter" => '"', + "FbAdapter" => '"' + }.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 + + def test_primary_key_and_references_columns_should_be_identical_type + pk = Author.columns_hash["id"] + ref = Post.columns_hash["author_id"] + + assert_equal pk.sql_type, ref.sql_type + end + + def test_many_mutations + car = Car.new name: "<3<3<3" + car.engines_count = 0 + 20_000.times { car.engines_count += 1 } + assert car.save + 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 + + 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_not_predicate NonExistentTable, :table_exists? + assert_predicate 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 subsecond_precision_supported? + 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 eastern_time_zone 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 eastern_time_zone 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 eastern_time_zone 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 eastern_time_zone 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 eastern_time_zone + if Gem.win_platform? + "EST5EDT" + else + "America/New_York" + 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 + ex = assert_raise(ActiveRecord::MultiparameterAssignmentErrors) do + Topic.new("title" => "test", + "written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00") + end + + assert_equal(1, ex.errors.size) + assert_equal("written_on", 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_not 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?(: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_predicate 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_predicate 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, 2]), Topic.find(["1-meowmeow", "2-hello"]) + assert_equal "The Second Topic of the day", Topic.find(["2-hello", "1-meowmeow"]).first.title + end + + def test_find_by_slug_with_range + assert_equal Topic.where(id: "1-meowmeow".."2-hello"), Topic.where(id: 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_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_attributes + category = Category.new(name: "Ruby") + + expected_attributes = category.attribute_names.map do |attribute_name| + [attribute_name, category.public_send(attribute_name)] + end.to_h + + assert_instance_of Hash, category.attributes + assert_equal expected_attributes, category.attributes + 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_not_predicate 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_predicate 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_not_predicate dup, :persisted? + + # test if the attributes have been duped + original_amount = dup.salary.amount + dev.salary.amount = 1 + assert_equal original_amount, dup.salary.amount + + assert dup.save + assert_predicate 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_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_not_predicate developer, :name_changed? + assert_not_predicate developer, :salary_changed? + + cloned_developer = developer.clone + assert_not_predicate cloned_developer, :name_changed? + assert_not_predicate cloned_developer, :salary_changed? + end + + def test_clone_of_new_object_marks_attributes_as_dirty + developer = Developer.new name: "Bjorn", salary: 100000 + assert_predicate developer, :name_changed? + assert_predicate developer, :salary_changed? + + cloned_developer = developer.clone + assert_predicate cloned_developer, :name_changed? + assert_predicate 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_not developer.salary_changed? # attribute has non-nil default value, so treated as not changed + + cloned_developer = developer.clone + assert_predicate cloned_developer, :name_changed? + assert_not 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_not_predicate developer, :name_changed? + assert_not_predicate developer, :salary_changed? + + cloned_developer = developer.dup + assert cloned_developer.name_changed? # both attributes differ from defaults + assert_predicate cloned_developer, :salary_changed? + end + + def test_dup_of_saved_object_marks_as_dirty_only_changed_attributes + developer = Developer.create! name: "Bjorn" + assert_not developer.name_changed? # both attributes of saved object should be treated as not changed + assert_not_predicate developer, :salary_changed? + + cloned_developer = developer.dup + assert cloned_developer.name_changed? # ... but on cloned object should be + assert_not 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 = 2147483648 + company.save + company = Company.find(1) + assert_equal 2147483648, company.rating + end + + def test_bignum_pk + company = Company.create!(id: 2147483648, name: "foo") + assert_equal company, Company.find(company.id) + end + + if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter, :SQLite3Adapter) + 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 + # Mysql text type can't have default value + unless current_adapter?(:Mysql2Adapter) + assert_equal "a text field", default.char3 + end + end + end + 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_not_predicate topics(:first), :approved? + topics(:first).toggle!(:approved) + assert_predicate topics(:first), :approved? + topic = topics(:first) + topic.toggle(:approved) + assert_not_predicate topic, :approved? + topic.reload + assert_predicate 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_switching_between_table_name + k = Class.new(Joke) + + assert_difference("GoodJoke.count") do + k.table_name = "cold_jokes" + k.create + + k.table_name = "funny_jokes" + k.create + end + end + + def test_clear_cache_when_setting_table_name + original_table_name = Joke.table_name + + Joke.table_name = "funny_jokes" + before_columns = Joke.columns + before_seq = Joke.sequence_name + + Joke.table_name = "cold_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? + ensure + Joke.table_name = original_table_name + end + + def test_dont_clear_sequence_name_when_setting_explicitly + k = Class.new(Joke) + k.sequence_name = "black_jokes_seq" + k.table_name = "cold_jokes" + before_seq = k.sequence_name + + k.table_name = "funny_jokes" + after_seq = k.sequence_name + + assert_equal before_seq, after_seq unless before_seq.nil? && after_seq.nil? + end + + def test_dont_clear_inheritance_column_when_setting_explicitly + k = Class.new(Joke) + k.inheritance_column = "my_type" + before_inherit = k.inheritance_column + + k.reset_column_information + after_inherit = k.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 + k = Class.new(Joke) + k.table_name = :cold_jokes + assert_equal "cold_jokes", k.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 + + 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 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").count + 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 = Post.where("p.#{QUOTED_TYPE} = 'Post' AND p.id=co.post_id").joins("p, comments co").select("p.id").distinct.count + 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_table_name + assert_nil AbstractCompany.table_name + 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.logger.level = Logger::DEBUG + 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_clear_cache! + # preheat cache + c1 = Post.connection.schema_cache.columns("posts") + ActiveRecord::Base.clear_cache! + c2 = Post.connection.schema_cache.columns("posts") + c1.each_with_index do |v, i| + assert_not_same v, c2[i] + end + assert_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 + klass = UnloadablePost + assert_not_nil ActiveRecord::Scoping::ScopeRegistry.value_for(:current_scope, klass) + ActiveSupport::Dependencies.remove_unloadable_constants! + assert_nil ActiveRecord::Scoping::ScopeRegistry.value_for(:current_scope, klass) + 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_has_attribute + assert Company.has_attribute?("id") + assert Company.has_attribute?("type") + assert Company.has_attribute?("name") + assert_not Company.has_attribute?("lastname") + assert_not Company.has_attribute?("age") + end + + def test_has_attribute_with_symbol + assert Company.has_attribute?(:id) + assert_not Company.has_attribute?(:age) + 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_distinct_delegates_to_scoped + assert_equal Bird.all.distinct, 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 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_slice_accepts_array_argument + attrs = { + title: "slice", + author_name: "@Cohen-Carlisle", + content: "accept arrays so I don't have to splat" + }.with_indifferent_access + topic = Topic.new(attrs) + assert_equal attrs, topic.slice(attrs.keys) + end + + def test_default_values_are_deeply_dupped + company = Company.new + company.description << "foo" + assert_equal "", Company.new.description + 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 = Concurrent::CountDownLatch.new + latch2 = Concurrent::CountDownLatch.new + + t = Thread.new do + klass.connection_handler = new_handler + latch1.count_down + latch2.wait + after_handler = klass.connection_handler + end + + latch1.wait + + klass.connection_handler = orig_handler + latch2.count_down + 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 + + test "records of different classes have different hashes" do + assert_not_equal Post.new(id: 1).hash, Developer.new(id: 1).hash + end + + test "resetting column information doesn't remove attribute methods" do + topic = topics(:first) + + assert_not_predicate topic, :id_changed? + + Topic.reset_column_information + + assert_not_predicate topic, :id_changed? + end + + test "ignored columns are not present in columns_hash" do + cache_columns = Developer.connection.schema_cache.columns_hash(Developer.table_name) + assert_includes cache_columns.keys, "first_name" + assert_not_includes Developer.columns_hash.keys, "first_name" + assert_not_includes SubDeveloper.columns_hash.keys, "first_name" + assert_not_includes SymbolIgnoredDeveloper.columns_hash.keys, "first_name" + end + + test "ignored columns have no attribute methods" do + assert_not_respond_to Developer.new, :first_name + assert_not_respond_to Developer.new, :first_name= + assert_not_respond_to Developer.new, :first_name? + assert_not_respond_to SubDeveloper.new, :first_name + assert_not_respond_to SubDeveloper.new, :first_name= + assert_not_respond_to SubDeveloper.new, :first_name? + assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name + assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name= + assert_not_respond_to SymbolIgnoredDeveloper.new, :first_name? + end + + test "ignored columns don't prevent explicit declaration of attribute methods" do + assert_respond_to Developer.new, :last_name + assert_respond_to Developer.new, :last_name= + assert_respond_to Developer.new, :last_name? + assert_respond_to SubDeveloper.new, :last_name + assert_respond_to SubDeveloper.new, :last_name= + assert_respond_to SubDeveloper.new, :last_name? + assert_respond_to SymbolIgnoredDeveloper.new, :last_name + assert_respond_to SymbolIgnoredDeveloper.new, :last_name= + assert_respond_to SymbolIgnoredDeveloper.new, :last_name? + end + + test "ignored columns are stored as an array of string" do + assert_equal(%w(first_name last_name), Developer.ignored_columns) + assert_equal(%w(first_name last_name), SymbolIgnoredDeveloper.ignored_columns) + end + + test "when #reload called, ignored columns' attribute methods are not defined" do + developer = Developer.create!(name: "Developer") + assert_not_respond_to developer, :first_name + assert_not_respond_to developer, :first_name= + + developer.reload + + assert_not_respond_to developer, :first_name + assert_not_respond_to developer, :first_name= + end + + test "when ignored attribute is loaded, cast type should be preferred over DB type" do + developer = AttributedDeveloper.create + developer.update_column :name, "name" + + loaded_developer = AttributedDeveloper.where(id: developer.id).select("*").first + assert_equal "Developer: name", loaded_developer.name + end + + test "ignored columns not included in SELECT" do + query = Developer.all.to_sql.downcase + + # ignored column + assert_not query.include?("first_name") + + # regular column + assert query.include?("name") + end + + test "column names are quoted when using #from clause and model has ignored columns" do + assert_not_empty Developer.ignored_columns + query = Developer.from("developers").to_sql + quoted_id = "#{Developer.quoted_table_name}.#{Developer.quoted_primary_key}" + + assert_match(/SELECT #{Regexp.escape(quoted_id)}.* FROM developers/, query) + end + + test "using table name qualified column names unless having SELECT list explicitly" do + assert_equal developers(:david), Developer.from("developers").joins(:shared_computers).take + end + + test "protected environments by default is an array with production" do + assert_equal ["production"], ActiveRecord::Base.protected_environments + end + + def test_protected_environments_are_stored_as_an_array_of_string + previous_protected_environments = ActiveRecord::Base.protected_environments + ActiveRecord::Base.protected_environments = [:staging, "production"] + assert_equal ["staging", "production"], ActiveRecord::Base.protected_environments + ensure + ActiveRecord::Base.protected_environments = previous_protected_environments + end + + test "creating a record raises if preventing writes" do + error = assert_raises ActiveRecord::ReadOnlyError do + ActiveRecord::Base.connection.while_preventing_writes do + Bird.create! name: "Bluejay" + end + end + + assert_match %r/\AWrite query attempted while in readonly mode: INSERT /, error.message + end + + test "updating a record raises if preventing writes" do + bird = Bird.create! name: "Bluejay" + + error = assert_raises ActiveRecord::ReadOnlyError do + ActiveRecord::Base.connection.while_preventing_writes do + bird.update! name: "Robin" + end + end + + assert_match %r/\AWrite query attempted while in readonly mode: UPDATE /, error.message + end + + test "deleting a record raises if preventing writes" do + bird = Bird.create! name: "Bluejay" + + error = assert_raises ActiveRecord::ReadOnlyError do + ActiveRecord::Base.connection.while_preventing_writes do + bird.destroy! + end + end + + assert_match %r/\AWrite query attempted while in readonly mode: DELETE /, error.message + end + + test "selecting a record does not raise if preventing writes" do + bird = Bird.create! name: "Bluejay" + + ActiveRecord::Base.connection.while_preventing_writes do + assert_equal bird, Bird.where(name: "Bluejay").first + end + end + + test "an explain query does not raise if preventing writes" do + Bird.create!(name: "Bluejay") + + ActiveRecord::Base.connection.while_preventing_writes do + assert_queries(2) { Bird.where(name: "Bluejay").explain } + end + end + + test "an empty transaction does not raise if preventing writes" do + ActiveRecord::Base.connection.while_preventing_writes do + assert_queries(2, ignore_none: true) do + Bird.transaction do + ActiveRecord::Base.connection.materialize_transactions + end + end + end + end +end diff --git a/activerecord/test/cases/batches_test.rb b/activerecord/test/cases/batches_test.rb new file mode 100644 index 0000000000..d21218a997 --- /dev/null +++ b/activerecord/test/cases/batches_test.rb @@ -0,0 +1,663 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/comment" +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 + + 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 + + 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(ArgumentError) 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 + + test "find_each should honor limit if passed a block" do + limit = @total - 1 + total = 0 + + Post.limit(limit).find_each do |post| + total += 1 + end + + assert_equal limit, total + end + + test "find_each should honor limit if no block is passed" do + limit = @total - 1 + total = 0 + + Post.limit(limit).find_each.each do |post| + total += 1 + end + + assert_equal limit, total + end + + def test_warn_if_order_scope_is_set + assert_called(ActiveRecord::Base.logger, :warn) do + Post.order("title").find_each { |post| post } + end + end + + def test_logger_not_required + previous_logger = ActiveRecord::Base.logger + ActiveRecord::Base.logger = nil + assert_nothing_raised do + Post.order("comments_count DESC").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_should_end_at_the_finish_option + assert_queries(6) do + Post.find_in_batches(batch_size: 1, finish: 5) 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" + def not_a_post.id; end + not_a_post.stub(:id, -> { raise StandardError.new("not_a_post had #id called on it") }) do + 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 + 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).id, posts.first.id + end + + def test_find_in_batches_should_error_on_ignore_the_order + assert_raise(ArgumentError) do + PostWithDefaultScope.find_in_batches(error_on_ignore: true) { } + end + end + + def test_find_in_batches_should_not_error_if_config_overridden + # Set the config option which will be overridden + prev = ActiveRecord::Base.error_on_ignored_order + ActiveRecord::Base.error_on_ignored_order = true + assert_nothing_raised do + PostWithDefaultScope.find_in_batches(error_on_ignore: false) { } + end + ensure + # Set back to default + ActiveRecord::Base.error_on_ignored_order = prev + end + + def test_find_in_batches_should_error_on_config_specified_to_error + # Set the config option + prev = ActiveRecord::Base.error_on_ignored_order + ActiveRecord::Base.error_on_ignored_order = true + assert_raise(ArgumentError) do + PostWithDefaultScope.find_in_batches() { } + end + ensure + # Set back to default + ActiveRecord::Base.error_on_ignored_order = prev + end + + def test_find_in_batches_should_not_error_by_default + assert_nothing_raised do + PostWithDefaultScope.find_in_batches() { } + end + 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_in_batches(batch_size: 1) do |batch| + assert_kind_of Array, batch + assert_kind_of Subscriber, batch.first + end + end + end + + def test_find_in_batches_should_return_an_enumerator + enum = nil + assert_no_queries 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 + + test "find_in_batches should honor limit if passed a block" do + limit = @total - 1 + total = 0 + + Post.limit(limit).find_in_batches do |batch| + total += batch.size + end + + assert_equal limit, total + end + + test "find_in_batches should honor limit if no block is passed" do + limit = @total - 1 + total = 0 + + Post.limit(limit).find_in_batches.each do |batch| + total += batch.size + end + + assert_equal limit, total + end + + def test_in_batches_should_not_execute_any_query + assert_no_queries do + assert_kind_of ActiveRecord::Batches::BatchEnumerator, Post.in_batches(of: 2) + end + end + + def test_in_batches_should_yield_relation_if_block_given + assert_queries(6) do + Post.in_batches(of: 2) do |relation| + assert_kind_of ActiveRecord::Relation, relation + end + end + end + + def test_in_batches_should_be_enumerable_if_no_block_given + assert_queries(6) do + Post.in_batches(of: 2).each do |relation| + assert_kind_of ActiveRecord::Relation, relation + end + end + end + + def test_in_batches_each_record_should_yield_record_if_block_is_given + assert_queries(6) do + Post.in_batches(of: 2).each_record do |post| + assert_predicate post.title, :present? + assert_kind_of Post, post + end + end + end + + def test_in_batches_each_record_should_return_enumerator_if_no_block_given + assert_queries(6) do + Post.in_batches(of: 2).each_record.with_index do |post, i| + assert_predicate post.title, :present? + assert_kind_of Post, post + end + end + end + + def test_in_batches_each_record_should_be_ordered_by_id + ids = Post.order("id ASC").pluck(:id) + assert_queries(6) do + Post.in_batches(of: 2).each_record.with_index do |post, i| + assert_equal ids[i], post.id + end + end + end + + def test_in_batches_update_all_affect_all_records + assert_queries(6 + 6) do # 6 selects, 6 updates + Post.in_batches(of: 2).update_all(title: "updated-title") + end + assert_equal Post.all.pluck(:title), ["updated-title"] * Post.count + end + + def test_in_batches_delete_all_should_not_delete_records_in_other_batches + not_deleted_count = Post.where("id <= 2").count + Post.where("id > 2").in_batches(of: 2).delete_all + assert_equal 0, Post.where("id > 2").count + assert_equal not_deleted_count, Post.count + end + + def test_in_batches_should_not_be_loaded + Post.in_batches(of: 1) do |relation| + assert_not_predicate relation, :loaded? + end + + Post.in_batches(of: 1, load: false) do |relation| + assert_not_predicate relation, :loaded? + end + end + + def test_in_batches_should_be_loaded + Post.in_batches(of: 1, load: true) do |relation| + assert_predicate relation, :loaded? + end + end + + def test_in_batches_if_not_loaded_executes_more_queries + assert_queries(@total + 1) do + Post.in_batches(of: 1, load: false) do |relation| + assert_not_predicate relation, :loaded? + end + end + end + + def test_in_batches_should_return_relations + assert_queries(@total + 1) do + Post.in_batches(of: 1) do |relation| + assert_kind_of ActiveRecord::Relation, relation + end + end + end + + def test_in_batches_should_start_from_the_start_option + post = Post.order("id ASC").where("id >= ?", 2).first + assert_queries(2) do + relation = Post.in_batches(of: 1, start: 2).first + assert_equal post, relation.first + end + end + + def test_in_batches_should_end_at_the_finish_option + post = Post.order("id DESC").where("id <= ?", 5).first + assert_queries(7) do + relation = Post.in_batches(of: 1, finish: 5, load: true).reverse_each.first + assert_equal post, relation.last + end + end + + def test_in_batches_shouldnt_execute_query_unless_needed + assert_queries(2) do + Post.in_batches(of: @total) { |relation| assert_kind_of ActiveRecord::Relation, relation } + end + + assert_queries(1) do + Post.in_batches(of: @total + 1) { |relation| assert_kind_of ActiveRecord::Relation, relation } + end + end + + def test_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.in_batches(of: 1) do |relation| + assert_kind_of ActiveRecord::Relation, relation + assert_kind_of Post, relation.first + end + end + end + + def test_in_batches_should_not_use_records_after_yielding_them_in_case_original_array_is_modified + not_a_post = +"not a post" + def not_a_post.id + raise StandardError.new("not_a_post had #id called on it") + end + + assert_nothing_raised do + Post.in_batches(of: 1) do |relation| + assert_kind_of ActiveRecord::Relation, relation + assert_kind_of Post, relation.first + + relation = [not_a_post] * relation.count + end + end + end + + def test_in_batches_should_not_ignore_default_scope_without_order_statements + special_posts_ids = SpecialPostWithDefaultScope.all.map(&:id).sort + posts = [] + SpecialPostWithDefaultScope.in_batches do |relation| + posts.concat(relation) + end + assert_equal special_posts_ids, posts.map(&:id) + end + + def test_in_batches_should_not_modify_passed_options + assert_nothing_raised do + Post.in_batches({ of: 42, start: 1 }.freeze) { } + end + end + + def test_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.in_batches(of: 1, start: start_nick) do |relation| + subscribers.concat(relation) + end + + assert_equal nick_order_subscribers[1..-1].map(&:id), subscribers.map(&:id) + end + + def test_in_batches_should_use_any_column_as_primary_key_when_start_is_not_specified + assert_queries(Subscriber.count + 1) do + Subscriber.in_batches(of: 1, load: true) do |relation| + assert_kind_of ActiveRecord::Relation, relation + assert_kind_of Subscriber, relation.first + end + end + end + + def test_in_batches_should_return_an_enumerator + enum = nil + assert_no_queries do + enum = Post.in_batches(of: 1) + end + assert_queries(4) do + enum.first(4) do |relation| + assert_kind_of ActiveRecord::Relation, relation + assert_kind_of Post, relation.first + end + end + end + + def test_in_batches_relations_should_not_overlap_with_each_other + seen_posts = [] + Post.in_batches(of: 2, load: true) do |relation| + relation.to_a.each do |post| + assert_not seen_posts.include?(post) + seen_posts << post + end + end + end + + def test_in_batches_relations_with_condition_should_not_overlap_with_each_other + seen_posts = [] + author_id = Post.first.author_id + posts_by_author = Post.where(author_id: author_id) + Post.in_batches(of: 2) do |batch| + seen_posts += batch.where(author_id: author_id) + end + + assert_equal posts_by_author.pluck(:id).sort, seen_posts.map(&:id).sort + end + + def test_in_batches_relations_update_all_should_not_affect_matching_records_in_other_batches + Post.update_all(author_id: 0) + person = Post.last + person.update(author_id: 1) + + Post.in_batches(of: 2) do |batch| + batch.where("author_id >= 1").update_all("author_id = author_id + 1") + end + assert_equal 2, person.reload.author_id # incremented only once + end + + 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 + + [true, false].each do |load| + test "in_batches should return limit records when limit is less than batch size and load is #{load}" do + limit = 3 + batch_size = 5 + total = 0 + + Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| + total += batch.count + end + + assert_equal limit, total + end + + test "in_batches should return limit records when limit is greater than batch size and load is #{load}" do + limit = 5 + batch_size = 3 + total = 0 + + Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| + total += batch.count + end + + assert_equal limit, total + end + + test "in_batches should return limit records when limit is a multiple of the batch size and load is #{load}" do + limit = 6 + batch_size = 3 + total = 0 + + Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| + total += batch.count + end + + assert_equal limit, total + end + + test "in_batches should return no records if the limit is 0 and load is #{load}" do + limit = 0 + batch_size = 1 + total = 0 + + Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| + total += batch.count + end + + assert_equal limit, total + end + + test "in_batches should return all if the limit is greater than the number of records when load is #{load}" do + limit = @total + 1 + batch_size = 1 + total = 0 + + Post.limit(limit).in_batches(of: batch_size, load: load) do |batch| + total += batch.count + end + + assert_equal @total, total + end + end + + test ".find_each respects table alias" do + assert_queries(1) do + table_alias = Post.arel_table.alias("omg_posts") + table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias) + predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) + + posts = ActiveRecord::Relation.create( + Post, + table: table_alias, + predicate_builder: predicate_builder + ) + posts.find_each { } + end + end + + test ".find_each bypasses the query cache for its own queries" do + Post.cache do + assert_queries(2) do + Post.find_each { } + Post.find_each { } + end + end + end + + test ".find_each does not disable the query cache inside the given block" do + Post.cache do + Post.find_each(start: 1, finish: 1) do |post| + assert_queries(1) do + post.comments.count + post.comments.count + end + end + end + end + + test ".find_in_batches bypasses the query cache for its own queries" do + Post.cache do + assert_queries(2) do + Post.find_in_batches { } + Post.find_in_batches { } + end + end + end + + test ".find_in_batches does not disable the query cache inside the given block" do + Post.cache do + Post.find_in_batches(start: 1, finish: 1) do |batch| + assert_queries(1) do + batch.first.comments.count + batch.first.comments.count + end + end + end + end + + test ".in_batches bypasses the query cache for its own queries" do + Post.cache do + assert_queries(2) do + Post.in_batches { } + Post.in_batches { } + end + end + end + + test ".in_batches does not disable the query cache inside the given block" do + Post.cache do + Post.in_batches(start: 1, finish: 1) do |relation| + assert_queries(1) do + relation.count + relation.count + end + end + 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..58abdb47f7 --- /dev/null +++ b/activerecord/test/cases/binary_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +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 + + 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..bd5f157ca1 --- /dev/null +++ b/activerecord/test/cases/bind_parameter_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/author" +require "models/post" + +if ActiveRecord::Base.connection.prepared_statements + module ActiveRecord + class BindParameterTest < ActiveRecord::TestCase + fixtures :topics, :authors, :author_addresses, :posts + + 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 + + def teardown + ActiveSupport::Notifications.unsubscribe(@subscription) + end + + def test_too_many_binds + bind_params_length = @connection.send(:bind_params_length) + + topics = Topic.where(id: (1 .. bind_params_length).to_a << 2**63) + assert_equal Topic.count, topics.count + + topics = Topic.where.not(id: (1 .. bind_params_length).to_a << 2**63) + assert_equal 0, topics.count + end + + def test_bind_from_join_in_subquery + subquery = Author.joins(:thinking_posts).where(name: "David") + scope = Author.from(subquery, "authors").where(id: 1) + assert_equal 1, scope.count + end + + def test_binds_are_logged + sub = Arel::Nodes::BindParam.new(1) + binds = [Relation::QueryAttribute.new("id", 1, Type::Value.new)] + sql = "select * from topics where id = #{sub.to_sql}" + + @connection.exec_query(sql, "SQL", binds) + + message = @subscriber.calls.find { |args| args[4][:sql] == sql } + assert_equal binds, message[4][:binds] + end + + def test_find_one_uses_binds + Topic.find(1) + message = @subscriber.calls.find { |args| args[4][:binds].any? { |attr| attr.value == 1 } } + assert message, "expected a message with binds" + end + + def test_logs_binds_after_type_cast + binds = [Relation::QueryAttribute.new("id", "10", Type::Integer.new)] + assert_logs_binds(binds) + end + + def test_logs_legacy_binds_after_type_cast + binds = [[@pk, "10"]] + assert_logs_binds(binds) + end + + def test_deprecate_supports_statement_cache + assert_deprecated { ActiveRecord::Base.connection.supports_statement_cache? } + end + + private + def assert_logs_binds(binds) + payload = { + name: "SQL", + sql: "select * from topics where id = ?", + binds: binds, + type_casted_binds: @connection.type_casted_binds(binds) + } + + 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/boolean_test.rb b/activerecord/test/cases/boolean_test.rb new file mode 100644 index 0000000000..ab9f974e2c --- /dev/null +++ b/activerecord/test/cases/boolean_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/boolean" + +class BooleanTest < ActiveRecord::TestCase + def test_boolean + b_nil = Boolean.create!(value: nil) + b_false = Boolean.create!(value: false) + b_true = Boolean.create!(value: true) + + assert_nil Boolean.find(b_nil.id).value + assert_not_predicate Boolean.find(b_false.id), :value? + assert_predicate Boolean.find(b_true.id), :value? + end + + def test_boolean_without_questionmark + b_true = Boolean.create!(value: true) + + subclass = Class.new(Boolean).find(b_true.id) + superclass = Boolean.find(b_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: "") + b_false = Boolean.create!(value: "0") + b_true = Boolean.create!(value: "1") + + assert_nil Boolean.find(b_blank.id).value + assert_not_predicate Boolean.find(b_false.id), :value? + assert_predicate Boolean.find(b_true.id), :value? + end + + def test_find_by_boolean_string + b_false = Boolean.create!(value: "false") + b_true = Boolean.create!(value: "true") + + assert_equal b_false, Boolean.find_by(value: "false") + assert_equal b_true, Boolean.find_by(value: "true") + end +end diff --git a/activerecord/test/cases/cache_key_test.rb b/activerecord/test/cases/cache_key_test.rb new file mode 100644 index 0000000000..3a06b1c795 --- /dev/null +++ b/activerecord/test/cases/cache_key_test.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class CacheKeyTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class CacheMe < ActiveRecord::Base + self.cache_versioning = false + end + + class CacheMeWithVersion < ActiveRecord::Base + self.cache_versioning = true + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:cache_mes, force: true) { |t| t.timestamps } + @connection.create_table(:cache_me_with_versions, force: true) { |t| t.timestamps } + end + + teardown do + @connection.drop_table :cache_mes, if_exists: true + @connection.drop_table :cache_me_with_versions, if_exists: true + end + + test "cache_key format is not too precise" do + record = CacheMe.create + key = record.cache_key + + assert_equal key, record.reload.cache_key + end + + test "cache_key has no version when versioning is on" do + record = CacheMeWithVersion.create + assert_equal "active_record/cache_key_test/cache_me_with_versions/#{record.id}", record.cache_key + end + + test "cache_version is only there when versioning is on" do + assert_predicate CacheMeWithVersion.create.cache_version, :present? + assert_not_predicate CacheMe.create.cache_version, :present? + end + + test "cache_key_with_version always has both key and version" do + r1 = CacheMeWithVersion.create + assert_equal "active_record/cache_key_test/cache_me_with_versions/#{r1.id}-#{r1.updated_at.utc.to_s(:usec)}", r1.cache_key_with_version + + r2 = CacheMe.create + assert_equal "active_record/cache_key_test/cache_mes/#{r2.id}-#{r2.updated_at.utc.to_s(:usec)}", r2.cache_key_with_version + end + + test "cache_version is the same when it comes from the DB or from the user" do + skip("Mysql2 does not return a string value for updated_at") if current_adapter?(:Mysql2Adapter) + + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_not_called(record_from_db, :updated_at) do + record_from_db.cache_version + end + + assert_equal record.cache_version, record_from_db.cache_version + end + + test "cache_version does not truncate zeros when timestamp ends in zeros" do + skip("Mysql2 does not return a string value for updated_at") if current_adapter?(:Mysql2Adapter) + + travel_to Time.now.beginning_of_day do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_not_called(record_from_db, :updated_at) do + record_from_db.cache_version + end + + assert_equal record.cache_version, record_from_db.cache_version + end + end + + test "cache_version calls updated_at when the value is generated at create time" do + record = CacheMeWithVersion.create + assert_called(record, :updated_at) do + record.cache_version + end + end + + test "cache_version does NOT call updated_at when value is from the database" do + skip("Mysql2 does not return a string value for updated_at") if current_adapter?(:Mysql2Adapter) + + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_not_called(record_from_db, :updated_at) do + record_from_db.cache_version + end + end + + test "cache_version does call updated_at when it is assigned via a Time object" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_called(record_from_db, :updated_at) do + record_from_db.updated_at = Time.now + record_from_db.cache_version + end + end + + test "cache_version does call updated_at when it is assigned via a string" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_called(record_from_db, :updated_at) do + record_from_db.updated_at = Time.now.to_s + record_from_db.cache_version + end + end + + test "cache_version does call updated_at when it is assigned via a hash" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.find(record.id) + assert_called(record_from_db, :updated_at) do + record_from_db.updated_at = { 1 => 2016, 2 => 11, 3 => 12, 4 => 1, 5 => 2, 6 => 3, 7 => 22 } + record_from_db.cache_version + end + end + + test "updated_at on class but not on instance raises an error" do + record = CacheMeWithVersion.create + record_from_db = CacheMeWithVersion.where(id: record.id).select(:id).first + assert_raises(ActiveModel::MissingAttributeError) do + record_from_db.cache_version + 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..4d3db912c5 --- /dev/null +++ b/activerecord/test/cases/calculations_test.rb @@ -0,0 +1,975 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" +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/numeric_data" +require "models/minivan" +require "models/speedometer" +require "models/ship_part" +require "models/treasure" +require "models/developer" +require "models/post" +require "models/comment" +require "models/rating" + +class CalculationsTest < ActiveRecord::TestCase + fixtures :companies, :accounts, :topics, :speedometers, :minivans, :books, :posts, :comments + + def test_should_sum_field + assert_equal 318, Account.sum(:credit_limit) + end + + def test_should_sum_arel_attribute + assert_equal 318, Account.sum(Account.arel_table[:credit_limit]) + end + + def test_should_average_field + value = Account.average(:credit_limit) + assert_equal 53.0, value + end + + def test_should_average_arel_attribute + value = Account.average(Account.arel_table[: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_to_d_as_average + if nil.respond_to?(:to_d) + assert_equal BigDecimal(0), NumericData.average(:bank_balance) + else + assert_nil NumericData.average(:bank_balance) + end + end + + def test_should_get_maximum_of_field + assert_equal 60, Account.maximum(:credit_limit) + end + + def test_should_get_maximum_of_arel_attribute + assert_equal 60, Account.maximum(Account.arel_table[: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_maximum_of_arel_attribute_with_include + assert_equal 55, Account.where("companies.name != 'Summit'").references(:companies).includes(:firm).maximum(Account.arel_table[:credit_limit]) + end + + def test_should_get_minimum_of_field + assert_equal 50, Account.minimum(:credit_limit) + end + + def test_should_get_minimum_of_arel_attribute + assert_equal 50, Account.minimum(Account.arel_table[: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_includes c.keys, 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_includes c.keys, 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_includes c.keys, 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_generate_valid_sql_with_joins_and_group + assert_nothing_raised do + AuditLog.joins(:developer).group(:id).count + end + end + + def test_should_calculate_against_given_relation + developer = Developer.create!(name: "developer") + developer.audit_logs.create!(message: "first log") + developer.audit_logs.create!(message: "second log") + + c = developer.audit_logs.joins(:developer).group(:id).count + + assert_equal developer.audit_logs.count, c.size + developer.audit_logs.each do |log| + assert_equal 1, c[log.id] + end + 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(4) + + assert_equal 3, accounts.count(:firm_id) + assert_equal 3, accounts.select(:firm_id).count + end + + def test_limit_should_apply_before_count_arel_attribute + accounts = Account.limit(4) + + firm_id_attribute = Account.arel_table[:firm_id] + assert_equal 3, accounts.count(firm_id_attribute) + assert_equal 3, accounts.select(firm_id_attribute).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.sql + assert_match "credit_limit, firm_name", e.sql + end + + def test_apply_distinct_in_count + queries = assert_sql do + Account.distinct.count + Account.group(:firm_id).distinct.count + end + + queries.each do |query| + # `table_alias_length` in `column_alias_for` would execute + # "SHOW max_identifier_length" statement in PostgreSQL adapter. + next if query == "SHOW max_identifier_length" + assert_match %r{\ASELECT(?! DISTINCT) COUNT\(DISTINCT\b}, query + end + end + + def test_count_with_eager_loading_and_custom_order + posts = Post.includes(:comments).order("comments.id") + assert_queries(1) { assert_equal 11, posts.count } + assert_queries(1) { assert_equal 11, posts.count(:all) } + end + + def test_count_with_eager_loading_and_custom_order_and_distinct + posts = Post.includes(:comments).order("comments.id").distinct + assert_queries(1) { assert_equal 11, posts.count } + assert_queries(1) { assert_equal 11, posts.count(:all) } + end + + def test_distinct_count_all_with_custom_select_and_order + accounts = Account.distinct.select("credit_limit % 10").order(Arel.sql("credit_limit % 10")) + assert_queries(1) { assert_equal 3, accounts.count(:all) } + assert_queries(1) { assert_equal 3, accounts.load.size } + end + + def test_distinct_count_with_order_and_limit + assert_equal 4, Account.distinct.order(:firm_id).limit(4).count + end + + def test_distinct_count_with_order_and_offset + assert_equal 4, Account.distinct.order(:firm_id).offset(2).count + end + + def test_distinct_count_with_order_and_limit_and_offset + assert_equal 4, Account.distinct.order(:firm_id).limit(4).offset(2).count + end + + def test_distinct_joins_count_with_order_and_limit + assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).count + end + + def test_distinct_joins_count_with_order_and_offset + assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).offset(2).count + end + + def test_distinct_joins_count_with_order_and_limit_and_offset + assert_equal 3, Account.joins(:firm).distinct.order(:firm_id).limit(3).offset(2).count + end + + def test_distinct_count_with_group_by_and_order_and_limit + assert_equal({ 6 => 2 }, Account.group(:firm_id).distinct.order("1 DESC").limit(1).count) + 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 + skip unless current_adapter?(:Mysql2Adapter, :SQLite3Adapter) + 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_selected_arel_attribute + assert_equal 5, Account.select(Account.arel_table[:firm_id]).count + assert_equal 4, Account.distinct.select(Account.arel_table[:firm_id]).count + end + + def test_count_with_column_parameter + assert_equal 5, Account.count(:firm_id) + end + + def test_count_with_arel_attribute + assert_equal 5, Account.count(Account.arel_table[:firm_id]) + end + + def test_count_with_arel_star + assert_equal 6, Account.count(Arel.star) + end + + def test_count_with_distinct + assert_equal 4, Account.select(:credit_limit).distinct.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_count_arel_attribute_in_joined_table_with + assert_equal 5, Account.joins(:firm).count(Company.arel_table[:id]) + assert_equal 4, Account.joins(:firm).distinct.count(Company.arel_table[:id]) + end + + def test_count_selected_arel_attribute_in_joined_table + assert_equal 5, Account.joins(:firm).select(Company.arel_table[:id]).count + assert_equal 4, Account.joins(:firm).distinct.select(Company.arel_table[:id]).count + 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_includes c.keys, firm_id } + end + + def test_should_count_field_of_root_table_with_conflicting_group_by_column + assert_equal({ 1 => 1 }, Firm.joins(:accounts).group(:firm_id).count) + assert_equal({ 1 => 1 }, Firm.joins(:accounts).group("accounts.firm_id").count) + 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_count_with_block + assert_equal 4, Account.count { |account| account.credit_limit.modulo(10).zero? } + end + + def test_should_sum_expression + if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter, :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 + + if current_adapter?(:Mysql2Adapter) + def test_from_option_with_specified_index + 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 + if current_adapter?(:OracleAdapter) + assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, nil]], Company.order(:id).limit(1).pluck + else + assert_equal [[1, "Firm", 1, nil, "37signals", nil, 1, nil, ""]], Company.order(:id).limit(1).pluck + end + 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_with_type_cast_does_not_corrupt_the_query_cache + topic = topics(:first) + relation = Topic.where(id: topic.id) + assert_queries 1 do + Topic.cache do + kind = relation.select(:written_on).load.first.read_attribute_before_type_cast(:written_on).class + relation.pluck(:written_on) + assert_kind_of kind, relation.select(:written_on).load.first.read_attribute_before_type_cast(:written_on) + end + end + end + + def test_pluck_and_distinct + assert_equal [50, 53, 55, 60], Account.order(:credit_limit).distinct.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(Arel.sql("DISTINCT credit_limit")).sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("DISTINCT accounts.credit_limit")).sort + assert_equal [50, 53, 55, 60], Account.pluck(Arel.sql("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(Arel.sql("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_with_includes_offset + assert_equal [5], Topic.includes(:replies).order(:id).offset(4).pluck(:id) + assert_equal [], Topic.includes(:replies).order(:id).offset(5).pluck(:id) + end + + def test_pluck_with_join + assert_equal [[2, 2], [4, 4]], Reply.includes(:topic).pluck(:id, :"topics.id") + end + + def test_group_by_with_limit + expected = { "Post" => 8, "SpecialPost" => 1 } + actual = Post.includes(:comments).group(:type).order(:type).limit(2).count("comments.id") + assert_equal expected, actual + end + + def test_group_by_with_offset + expected = { "SpecialPost" => 1, "StiPost" => 2 } + actual = Post.includes(:comments).group(:type).order(:type).offset(1).count("comments.id") + assert_equal expected, actual + end + + def test_group_by_with_limit_and_offset + expected = { "SpecialPost" => 1 } + actual = Post.includes(:comments).group(:type).order(:type).offset(1).limit(1).count("comments.id") + assert_equal expected, actual + 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 + + def test_calculation_with_polymorphic_relation + part = ShipPart.create!(name: "has trinket") + part.trinkets.create! + + assert_equal part.id, ShipPart.joins(:trinkets).sum(:id) + end + + def test_pluck_joined_with_polymorphic_relation + part = ShipPart.create!(name: "has trinket") + part.trinkets.create! + + assert_equal [part.id], ShipPart.joins(:trinkets).pluck(:id) + end + + def test_pluck_loaded_relation + Company.attribute_names # Load schema information so we don't query below + companies = Company.order(:id).limit(3).load + + assert_no_queries do + assert_equal ["37signals", "Summit", "Microsoft"], companies.pluck(:name) + end + end + + def test_pluck_loaded_relation_multiple_columns + Company.attribute_names # Load schema information so we don't query below + companies = Company.order(:id).limit(3).load + + assert_no_queries do + assert_equal [[1, "37signals"], [2, "Summit"], [3, "Microsoft"]], companies.pluck(:id, :name) + end + end + + def test_pluck_loaded_relation_sql_fragment + Company.attribute_names # Load schema information so we don't query below + companies = Company.order(:name).limit(3).load + + assert_queries 1 do + assert_equal ["37signals", "Apex", "Ex Nihilo"], companies.pluck(Arel.sql("DISTINCT name")) + end + end + + def test_pick_one + assert_equal "The First Topic", Topic.order(:id).pick(:heading) + assert_nil Topic.none.pick(:heading) + assert_nil Topic.where("1=0").pick(:heading) + end + + def test_pick_two + assert_equal ["David", "david@loudthinking.com"], Topic.order(:id).pick(:author_name, :author_email_address) + assert_nil Topic.none.pick(:author_name, :author_email_address) + assert_nil Topic.where("1=0").pick(:author_name, :author_email_address) + end + + def test_pick_delegate_to_all + cool_first = minivans(:cool_first) + assert_equal cool_first.color, Minivan.pick(:color) + end + + def test_grouped_calculation_with_polymorphic_relation + part = ShipPart.create!(name: "has trinket") + part.trinkets.create! + + assert_equal({ "has trinket" => part.id }, ShipPart.joins(:trinkets).group("ship_parts.name").sum(:id)) + end + + def test_calculation_grouped_by_association_doesnt_error_when_no_records_have_association + Client.update_all(client_of: nil) + assert_equal({ nil => Client.count }, Client.group(:firm).count) + end + + def test_should_reference_correct_aliases_while_joining_tables_of_has_many_through_association + assert_nothing_raised do + developer = Developer.create!(name: "developer") + developer.ratings.includes(comment: :post).where(posts: { id: 1 }).count + end + end + + def test_sum_uses_enumerable_version_when_block_is_given + block_called = false + relation = Client.all.load + + assert_no_queries do + assert_equal 0, relation.sum { block_called = true; 0 } + end + assert block_called + end + + def test_having_with_strong_parameters + protected_params = Class.new do + attr_reader :permitted + alias :permitted? :permitted + + def initialize(parameters) + @parameters = parameters + @permitted = false + end + + def to_h + @parameters + end + + def permit! + @permitted = true + self + end + end + + params = protected_params.new(credit_limit: "50") + + assert_raises(ActiveModel::ForbiddenAttributesError) do + Account.group(:id).having(params) + end + + result = Account.group(:id).having(params.permit!) + assert_equal 50, result[0].credit_limit + assert_equal 50, result[1].credit_limit + assert_equal 50, result[2].credit_limit + end + + def test_group_by_attribute_with_custom_type + assert_equal({ "proposed" => 2, "published" => 2 }, Book.group(:status).count) + end + + def test_deprecate_count_with_block_and_column_name + assert_deprecated do + assert_equal 6, Account.count(:firm_id) { true } + end + end + + def test_deprecate_sum_with_block_and_column_name + assert_deprecated do + assert_equal 6, Account.sum(:firm_id) { 1 } + end + end + + test "#skip_query_cache! for #pluck" do + Account.cache do + assert_queries(1) do + Account.pluck(:credit_limit) + Account.pluck(:credit_limit) + end + + assert_queries(2) do + Account.all.skip_query_cache!.pluck(:credit_limit) + Account.all.skip_query_cache!.pluck(:credit_limit) + end + end + end + + test "#skip_query_cache! for a simple calculation" do + Account.cache do + assert_queries(1) do + Account.calculate(:sum, :credit_limit) + Account.calculate(:sum, :credit_limit) + end + + assert_queries(2) do + Account.all.skip_query_cache!.calculate(:sum, :credit_limit) + Account.all.skip_query_cache!.calculate(:sum, :credit_limit) + end + end + end + + test "#skip_query_cache! for a grouped calculation" do + Account.cache do + assert_queries(1) do + Account.group(:firm_id).calculate(:sum, :credit_limit) + Account.group(:firm_id).calculate(:sum, :credit_limit) + end + + assert_queries(2) do + Account.all.skip_query_cache!.group(:firm_id).calculate(:sum, :credit_limit) + Account.all.skip_query_cache!.group(:firm_id).calculate(:sum, :credit_limit) + end + end + end +end diff --git a/activerecord/test/cases/callbacks_test.rb b/activerecord/test/cases/callbacks_test.rb new file mode 100644 index 0000000000..4d6a112af5 --- /dev/null +++ b/activerecord/test/cases/callbacks_test.rb @@ -0,0 +1,506 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/computer" + +class CallbackDeveloper < ActiveRecord::Base + self.table_name = "developers" + + class << self + 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 + history << [callback_method, :method] + end + send(callback_method, :"#{callback_method}") + end + + def callback_object(callback_method) + klass = Class.new + klass.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.start_with?("around_") + define_callback_method(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 CallbackDeveloperWithHaltedValidation < CallbackDeveloper + before_validation proc { |model| model.history << [:before_validation, :throwing_abort]; throw(:abort) } + 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 ImmutableDeveloper < ActiveRecord::Base + self.table_name = "developers" + + validates_inclusion_of :salary, in: 50000..200000 + + before_save :cancel + before_destroy :cancel + + private + def cancel + false + end +end + +class DeveloperWithCanceledCallbacks < ActiveRecord::Base + self.table_name = "developers" + + validates_inclusion_of :salary, in: 50000..200000 + + before_save :cancel + before_destroy :cancel + + private + def cancel + throw(:abort) + 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_#{validation_context}".to_sym + end + + def after_validation_on_create_and_update + history << "after_validation_on_#{validation_context}".to_sym + end + + def history + @history ||= [] + end +end + +class CallbackHaltedDeveloper < 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 { throw(:abort) if defined?(@cancel_before_save) } + before_create { throw(:abort) if @cancel_before_create } + before_update { throw(:abort) if @cancel_before_update } + before_destroy { throw(:abort) if @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, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + ], david.history + end + + def test_find + david = CallbackDeveloper.find(1) + assert_equal [ + [ :after_find, :method ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :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, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :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, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :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, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :after_validation, :proc ], + [ :after_validation, :object ], + [ :after_validation, :block ], + [ :before_save, :method ], + [ :before_save, :proc ], + [ :before_save, :object ], + [ :before_save, :block ], + [ :before_create, :method ], + [ :before_create, :proc ], + [ :before_create, :object ], + [ :before_create, :block ], + [ :after_create, :method ], + [ :after_create, :proc ], + [ :after_create, :object ], + [ :after_create, :block ], + [ :after_save, :method ], + [ :after_save, :proc ], + [ :after_save, :object ], + [ :after_save, :block ], + [ :after_commit, :block ], + [ :after_commit, :object ], + [ :after_commit, :proc ], + [ :after_commit, :method ] + ], 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, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :after_validation, :method ], + [ :after_validation, :proc ], + [ :after_validation, :object ], + [ :after_validation, :block ], + [ :before_save, :method ], + [ :before_save, :proc ], + [ :before_save, :object ], + [ :before_save, :block ], + [ :before_update, :method ], + [ :before_update, :proc ], + [ :before_update, :object ], + [ :before_update, :block ], + [ :after_update, :method ], + [ :after_update, :proc ], + [ :after_update, :object ], + [ :after_update, :block ], + [ :after_save, :method ], + [ :after_save, :proc ], + [ :after_save, :object ], + [ :after_save, :block ], + [ :after_commit, :block ], + [ :after_commit, :object ], + [ :after_commit, :proc ], + [ :after_commit, :method ] + ], 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, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_destroy, :method ], + [ :before_destroy, :proc ], + [ :before_destroy, :object ], + [ :before_destroy, :block ], + [ :after_destroy, :method ], + [ :after_destroy, :proc ], + [ :after_destroy, :object ], + [ :after_destroy, :block ], + [ :after_commit, :block ], + [ :after_commit, :object ], + [ :after_commit, :proc ], + [ :after_commit, :method ] + ], david.history + end + + def test_delete + david = CallbackDeveloper.find(1) + CallbackDeveloper.delete(david.id) + assert_equal [ + [ :after_find, :method ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + ], david.history + end + + def assert_save_callbacks_not_called(someone) + assert_not someone.after_save_called + assert_not someone.after_create_called + assert_not someone.after_update_called + end + private :assert_save_callbacks_not_called + + def test_before_create_throwing_abort + someone = CallbackHaltedDeveloper.new + someone.cancel_before_create = true + assert_predicate someone, :valid? + assert_not someone.save + assert_save_callbacks_not_called(someone) + end + + def test_before_save_throwing_abort + david = DeveloperWithCanceledCallbacks.find(1) + assert_predicate david, :valid? + assert_not david.save + exc = assert_raise(ActiveRecord::RecordNotSaved) { david.save! } + assert_equal david, exc.record + + david = DeveloperWithCanceledCallbacks.find(1) + david.salary = 10_000_000 + assert_not_predicate david, :valid? + assert_not david.save + assert_raise(ActiveRecord::RecordInvalid) { david.save! } + + someone = CallbackHaltedDeveloper.find(1) + someone.cancel_before_save = true + assert_predicate someone, :valid? + assert_not someone.save + assert_save_callbacks_not_called(someone) + end + + def test_before_update_throwing_abort + someone = CallbackHaltedDeveloper.find(1) + someone.cancel_before_update = true + assert_predicate someone, :valid? + assert_not someone.save + assert_save_callbacks_not_called(someone) + end + + def test_before_destroy_throwing_abort + david = DeveloperWithCanceledCallbacks.find(1) + assert_not david.destroy + exc = assert_raise(ActiveRecord::RecordNotDestroyed) { david.destroy! } + assert_equal david, exc.record + assert_not_nil ImmutableDeveloper.find_by_id(1) + + someone = CallbackHaltedDeveloper.find(1) + someone.cancel_before_destroy = true + assert_not someone.destroy + assert_raise(ActiveRecord::RecordNotDestroyed) { someone.destroy! } + assert_not someone.after_destroy_called + end + + def test_callback_throwing_abort + david = CallbackDeveloperWithHaltedValidation.find(1) + david.save + assert_equal [ + [ :after_find, :method ], + [ :after_find, :proc ], + [ :after_find, :object ], + [ :after_find, :block ], + [ :after_initialize, :method ], + [ :after_initialize, :proc ], + [ :after_initialize, :object ], + [ :after_initialize, :block ], + [ :before_validation, :method ], + [ :before_validation, :proc ], + [ :before_validation, :object ], + [ :before_validation, :block ], + [ :before_validation, :throwing_abort ], + [ :after_rollback, :block ], + [ :after_rollback, :object ], + [ :after_rollback, :proc ], + [ :after_rollback, :method ], + ], david.history + end + + def test_inheritance_of_callbacks + parent = ParentDeveloper.new + assert_not parent.after_save_called + parent.save + assert parent.after_save_called + + child = ChildDeveloper.new + assert_not child.after_save_called + child.save + assert child.after_save_called + end + + def test_before_save_doesnt_allow_on_option + exception = assert_raises ArgumentError do + Class.new(ActiveRecord::Base) do + before_save(on: :create) { } + end + end + assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message + end + + def test_around_save_doesnt_allow_on_option + exception = assert_raises ArgumentError do + Class.new(ActiveRecord::Base) do + around_save(on: :create) { } + end + end + assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message + end + + def test_after_save_doesnt_allow_on_option + exception = assert_raises ArgumentError do + Class.new(ActiveRecord::Base) do + after_save(on: :create) { } + end + end + assert_equal "Unknown key: :on. Valid keys are: :if, :unless, :prepend", exception.message + end +end diff --git a/activerecord/test/cases/clone_test.rb b/activerecord/test/cases/clone_test.rb new file mode 100644 index 0000000000..eea36ee736 --- /dev/null +++ b/activerecord/test/cases/clone_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +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_not 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_not 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_predicate clone, :frozen? + end + end +end diff --git a/activerecord/test/cases/coders/json_test.rb b/activerecord/test/cases/coders/json_test.rb new file mode 100644 index 0000000000..e40d576b39 --- /dev/null +++ b/activerecord/test/cases/coders/json_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module Coders + class JSONTest < ActiveRecord::TestCase + def test_returns_nil_if_empty_string_given + assert_nil JSON.load("") + end + + def test_returns_nil_if_nil_given + assert_nil JSON.load(nil) + end + 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..4a5559c62f --- /dev/null +++ b/activerecord/test/cases/coders/yaml_column_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module Coders + class YAMLColumnTest < ActiveRecord::TestCase + def test_initialize_takes_class + coder = YAMLColumn.new("attr_name", Object) + assert_equal Object, coder.object_class + end + + def test_type_mismatch_on_different_classes_on_dump + coder = YAMLColumn.new("tags", Array) + error = assert_raises(SerializationTypeMismatch) do + coder.dump("a") + end + assert_equal %{can't dump `tags`: was supposed to be a Array, but was a String. -- "a"}, error.to_s + end + + def test_type_mismatch_on_different_classes + coder = YAMLColumn.new("tags", Array) + error = assert_raises(SerializationTypeMismatch) do + coder.load "--- foo" + end + assert_equal %{can't load `tags`: was supposed to be a Array, but was a String. -- "foo"}, error.to_s + end + + def test_nil_is_ok + coder = YAMLColumn.new("attr_name") + assert_nil coder.load "--- " + end + + def test_returns_new_with_different_class + coder = YAMLColumn.new("attr_name", SerializationTypeMismatch) + assert_equal SerializationTypeMismatch, coder.load("--- ").class + end + + def test_returns_string_unless_starts_with_dash + coder = YAMLColumn.new("attr_name") + assert_equal "foo", coder.load("foo") + end + + def test_load_handles_other_classes + coder = YAMLColumn.new("attr_name") + assert_equal [], coder.load([]) + end + + def test_load_doesnt_swallow_yaml_exceptions + coder = YAMLColumn.new("attr_name") + 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("attr_name") + 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/collection_cache_key_test.rb b/activerecord/test/cases/collection_cache_key_test.rb new file mode 100644 index 0000000000..483383257b --- /dev/null +++ b/activerecord/test/cases/collection_cache_key_test.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/computer" +require "models/developer" +require "models/project" +require "models/topic" +require "models/post" +require "models/comment" + +module ActiveRecord + class CollectionCacheKeyTest < ActiveRecord::TestCase + fixtures :developers, :projects, :developers_projects, :topics, :comments, :posts + + test "collection_cache_key on model" do + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, Developer.collection_cache_key) + end + + test "cache_key for relation" do + developers = Developer.where(salary: 100000).order(updated_at: :desc) + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for relation with limit" do + developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5) + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for relation with custom select and limit" do + developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5) + developers_with_select = developers.select("developers.*") + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers_with_select.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers_with_select.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers_with_select.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for loaded relation" do + developers = Developer.where(salary: 100000).order(updated_at: :desc).limit(5).load + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for relation with table alias" do + table_alias = Developer.arel_table.alias("omg_developers") + table_metadata = ActiveRecord::TableMetadata.new(Developer, table_alias) + predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) + + developers = ActiveRecord::Relation.create( + Developer, + table: table_alias, + predicate_builder: predicate_builder + ) + developers = developers.where(salary: 100000).order(updated_at: :desc) + last_developer_timestamp = developers.first.updated_at + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + + /\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/ =~ developers.cache_key + + assert_equal ActiveSupport::Digest.hexdigest(developers.to_sql), $1 + assert_equal developers.count.to_s, $2 + assert_equal last_developer_timestamp.to_s(ActiveRecord::Base.cache_timestamp_format), $3 + end + + test "cache_key for relation with includes" do + comments = Comment.includes(:post).where("posts.type": "Post") + assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key) + end + + test "cache_key for loaded relation with includes" do + comments = Comment.includes(:post).where("posts.type": "Post").load + assert_match(/\Acomments\/query-(\h+)-(\d+)-(\d+)\z/, comments.cache_key) + end + + test "it triggers at most one query" do + developers = Developer.where(name: "David") + + assert_queries(1) { developers.cache_key } + assert_no_queries { developers.cache_key } + end + + test "it doesn't trigger any query if the relation is already loaded" do + developers = Developer.where(name: "David").load + assert_no_queries { developers.cache_key } + end + + test "relation cache_key changes when the sql query changes" do + developers = Developer.where(name: "David") + other_relation = Developer.where(name: "David").where("1 = 1") + + assert_not_equal developers.cache_key, other_relation.cache_key + end + + test "cache_key for empty relation" do + developers = Developer.where(name: "Non Existent Developer") + assert_match(/\Adevelopers\/query-(\h+)-0\z/, developers.cache_key) + end + + test "cache_key with custom timestamp column" do + topics = Topic.where("title like ?", "%Topic%") + last_topic_timestamp = topics(:fifth).written_on.utc.to_s(:usec) + assert_match(last_topic_timestamp, topics.cache_key(:written_on)) + end + + test "cache_key with unknown timestamp column" do + topics = Topic.where("title like ?", "%Topic%") + assert_raises(ActiveRecord::StatementInvalid) { topics.cache_key(:published_at) } + end + + test "collection proxy provides a cache_key" do + developers = projects(:active_record).developers + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + end + + test "cache_key for loaded collection with zero size" do + Comment.delete_all + posts = Post.includes(:comments) + empty_loaded_collection = posts.first.comments + + assert_match(/\Acomments\/query-(\h+)-0\z/, empty_loaded_collection.cache_key) + end + + test "cache_key for queries with offset which return 0 rows" do + developers = Developer.offset(20) + assert_match(/\Adevelopers\/query-(\h+)-0\z/, developers.cache_key) + end + + test "cache_key with a relation having selected columns" do + developers = Developer.select(:salary) + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + end + + test "cache_key with a relation having distinct and order" do + developers = Developer.distinct.order(:salary).limit(5) + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + end + + test "cache_key with a relation having custom select and order" do + developers = Developer.select("name AS dev_name").order("dev_name DESC").limit(5) + + assert_match(/\Adevelopers\/query-(\h+)-(\d+)-(\d+)\z/, developers.cache_key) + 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..a883d21fb8 --- /dev/null +++ b/activerecord/test/cases/column_alias_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +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..cbd2b44589 --- /dev/null +++ b/activerecord/test/cases/column_definition_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +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.send(: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_def = ColumnDefinition.new("title", "string", limit: 20) + assert_equal "title varchar(20)", @viz.accept(column_def) + end + + def test_should_include_default_clause_when_default_is_present + column_def = ColumnDefinition.new("title", "string", limit: 20, default: "Hello") + assert_equal "title varchar(20) DEFAULT 'Hello'", @viz.accept(column_def) + end + + def test_should_specify_not_null_if_null_option_is_false + column_def = ColumnDefinition.new("title", "string", limit: 20, default: "Hello", null: false) + assert_equal "title varchar(20) DEFAULT 'Hello' NOT NULL", @viz.accept(column_def) + end + end + end +end diff --git a/activerecord/test/cases/comment_test.rb b/activerecord/test/cases/comment_test.rb new file mode 100644 index 0000000000..584e03d196 --- /dev/null +++ b/activerecord/test/cases/comment_test.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +if ActiveRecord::Base.connection.supports_comments? + class CommentTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + class Commented < ActiveRecord::Base + self.table_name = "commenteds" + end + + class BlankComment < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + + @connection.create_table("commenteds", comment: "A table with comment", force: true) do |t| + t.string "name", comment: "Comment should help clarify the column purpose" + t.boolean "obvious", comment: "Question is: should you comment obviously named objects?" + t.string "content" + t.index "name", comment: %Q["Very important" index that powers all the performance.\nAnd it's fun!] + end + + @connection.create_table("blank_comments", comment: " ", force: true) do |t| + t.string :space_comment, comment: " " + t.string :empty_comment, comment: "" + t.string :nil_comment, comment: nil + t.string :absent_comment + t.index :space_comment, comment: " " + t.index :empty_comment, comment: "" + t.index :nil_comment, comment: nil + t.index :absent_comment + end + + Commented.reset_column_information + BlankComment.reset_column_information + end + + teardown do + @connection.drop_table "commenteds", if_exists: true + @connection.drop_table "blank_comments", if_exists: true + end + + def test_column_created_in_block + column = Commented.columns_hash["name"] + assert_equal :string, column.type + assert_equal "Comment should help clarify the column purpose", column.comment + end + + def test_blank_columns_created_in_block + %w[ space_comment empty_comment nil_comment absent_comment ].each do |field| + column = BlankComment.columns_hash[field] + assert_equal :string, column.type + assert_nil column.comment + end + end + + def test_blank_indexes_created_in_block + @connection.indexes("blank_comments").each do |index| + assert_nil index.comment + end + end + + def test_add_column_with_comment_later + @connection.add_column :commenteds, :rating, :integer, comment: "I am running out of imagination" + Commented.reset_column_information + column = Commented.columns_hash["rating"] + + assert_equal :integer, column.type + assert_equal "I am running out of imagination", column.comment + end + + def test_add_index_with_comment_later + unless current_adapter?(:OracleAdapter) + @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments" + index = @connection.indexes("commenteds").find { |idef| idef.name == "idx_obvious" } + assert_equal "We need to see obvious comments", index.comment + end + end + + def test_add_comment_to_column + @connection.change_column :commenteds, :content, :string, comment: "Whoa, content describes itself!" + + Commented.reset_column_information + column = Commented.columns_hash["content"] + + assert_equal :string, column.type + assert_equal "Whoa, content describes itself!", column.comment + end + + def test_remove_comment_from_column + @connection.change_column :commenteds, :obvious, :string, comment: nil + + Commented.reset_column_information + column = Commented.columns_hash["obvious"] + + assert_equal :string, column.type + assert_nil column.comment + end + + def test_schema_dump_with_comments + # Do all the stuff from other tests + @connection.add_column :commenteds, :rating, :integer, comment: "I am running out of imagination" + @connection.change_column :commenteds, :content, :string, comment: "Whoa, content describes itself!" + @connection.change_column :commenteds, :content, :string + @connection.change_column :commenteds, :obvious, :string, comment: nil + @connection.add_index :commenteds, :obvious, name: "idx_obvious", comment: "We need to see obvious comments" + + # And check that these changes are reflected in dump + output = dump_table_schema "commenteds" + assert_match %r[create_table "commenteds",.*\s+comment: "A table with comment"], output + assert_match %r[t\.string\s+"name",\s+comment: "Comment should help clarify the column purpose"], output + assert_match %r[t\.string\s+"obvious"\n], output + assert_match %r[t\.string\s+"content",\s+comment: "Whoa, content describes itself!"], output + if current_adapter?(:OracleAdapter) + assert_match %r[t\.integer\s+"rating",\s+precision: 38,\s+comment: "I am running out of imagination"], output + else + assert_match %r[t\.integer\s+"rating",\s+comment: "I am running out of imagination"], output + assert_match %r[t\.index\s+.+\s+comment: "\\\"Very important\\\" index that powers all the performance.\\nAnd it's fun!"], output + assert_match %r[t\.index\s+.+\s+name: "idx_obvious",\s+comment: "We need to see obvious comments"], output + end + end + + def test_schema_dump_omits_blank_comments + output = dump_table_schema "blank_comments" + + assert_match %r[create_table "blank_comments"], output + assert_no_match %r[create_table "blank_comments",.+comment:], output + + assert_match %r[t\.string\s+"space_comment"\n], output + assert_no_match %r[t\.string\s+"space_comment", comment:\n], output + + assert_match %r[t\.string\s+"empty_comment"\n], output + assert_no_match %r[t\.string\s+"empty_comment", comment:\n], output + + assert_match %r[t\.string\s+"nil_comment"\n], output + assert_no_match %r[t\.string\s+"nil_comment", comment:\n], output + + assert_match %r[t\.string\s+"absent_comment"\n], output + assert_no_match %r[t\.string\s+"absent_comment", comment:\n], output + end + + def test_change_table_comment + @connection.change_table_comment :commenteds, "Edited table comment" + assert_equal "Edited table comment", @connection.table_comment("commenteds") + end + + def test_change_table_comment_to_nil + @connection.change_table_comment :commenteds, nil + assert_nil @connection.table_comment("commenteds") + end + + def test_change_column_comment + @connection.change_column_comment :commenteds, :name, "Edited column comment" + column = Commented.columns_hash["name"] + assert_equal "Edited column comment", column.comment + end + + def test_change_column_comment_to_nil + @connection.change_column_comment :commenteds, :name, nil + column = Commented.columns_hash["name"] + assert_nil column.comment + 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..72838ff56b --- /dev/null +++ b/activerecord/test/cases/connection_adapters/adapter_leasing_test.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class AdapterLeasingTest < ActiveRecord::TestCase + class Pool < ConnectionPool + def insert_connection_for_test!(c) + synchronize do + adopt_connection(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_raises(ActiveRecordError) do + @adapter.lease + end + 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("primary", {}, 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_predicate @adapter, :in_use? + + # Close should put the adapter back in the pool + @adapter.close + assert_not_predicate @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..51d0cc3d12 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/connection_handler_test.rb @@ -0,0 +1,388 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/person" + +module ActiveRecord + module ConnectionAdapters + class ConnectionHandlerTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :people + + def setup + @handler = ConnectionHandler.new + @spec_name = "primary" + @pool = @handler.establish_connection(ActiveRecord::Base.configurations["arunit"]) + end + + def test_default_env_fall_back_to_default_env_when_rails_env_or_rack_env_is_empty_string + original_rails_env = ENV["RAILS_ENV"] + original_rack_env = ENV["RACK_ENV"] + ENV["RAILS_ENV"] = ENV["RACK_ENV"] = "" + + assert_equal "default_env", ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + ensure + ENV["RAILS_ENV"] = original_rails_env + ENV["RACK_ENV"] = original_rack_env + end + + def test_establish_connection_uses_spec_name + old_config = ActiveRecord::Base.configurations + config = { "readonly" => { "adapter" => "sqlite3" } } + ActiveRecord::Base.configurations = config + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(ActiveRecord::Base.configurations) + spec = resolver.spec(:readonly) + @handler.establish_connection(spec.to_hash) + + assert_not_nil @handler.retrieve_connection_pool("readonly") + ensure + ActiveRecord::Base.configurations = old_config + @handler.remove_connection("readonly") + end + + def test_establish_connection_using_3_levels_config + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } + }, + "another_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/bad-readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/bad-primary.sqlite3" } + }, + "common" => { "adapter" => "sqlite3", "database" => "db/common.sqlite3" } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + @handler.establish_connection(:common) + @handler.establish_connection(:primary) + @handler.establish_connection(:readonly) + + assert_not_nil pool = @handler.retrieve_connection_pool("readonly") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + + assert_not_nil pool = @handler.retrieve_connection_pool("primary") + assert_equal "db/primary.sqlite3", pool.spec.config[:database] + + assert_not_nil pool = @handler.retrieve_connection_pool("common") + assert_equal "db/common.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ENV["RAILS_ENV"] = previous_env + end + + unless in_memory_db? + def test_establish_connection_using_3_level_config_defaults_to_default_env_primary_db + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + }, + "another_env" => { + "primary" => { "adapter" => "sqlite3", "database" => "db/another-primary.sqlite3" }, + "readonly" => { "adapter" => "sqlite3", "database" => "db/another-readonly.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.establish_connection + + assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ENV["RAILS_ENV"] = previous_env + ActiveRecord::Base.establish_connection(:arunit) + FileUtils.rm_rf "db" + end + + def test_establish_connection_using_2_level_config_defaults_to_default_env_primary_db + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "adapter" => "sqlite3", "database" => "db/primary.sqlite3" + }, + "another_env" => { + "adapter" => "sqlite3", "database" => "db/bad-primary.sqlite3" + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.establish_connection + + assert_match "db/primary.sqlite3", ActiveRecord::Base.connection.pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ENV["RAILS_ENV"] = previous_env + ActiveRecord::Base.establish_connection(:arunit) + FileUtils.rm_rf "db" + end + end + + def test_establish_connection_using_two_level_configurations + config = { "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + @handler.establish_connection(:development) + + assert_not_nil pool = @handler.retrieve_connection_pool("development") + assert_equal "db/primary.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + end + + def test_establish_connection_using_top_level_key_in_two_level_config + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + @handler.establish_connection(:development_readonly) + + assert_not_nil pool = @handler.retrieve_connection_pool("development_readonly") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + end + + def test_symbolized_configurations_assignment + @prev_configs = ActiveRecord::Base.configurations + config = { + development: { + primary: { + adapter: "sqlite3", + database: "db/development.sqlite3", + }, + }, + test: { + primary: { + adapter: "sqlite3", + database: "db/test.sqlite3", + }, + }, + } + ActiveRecord::Base.configurations = config + ActiveRecord::Base.configurations.configs_for.each do |db_config| + assert_instance_of ActiveRecord::DatabaseConfigurations::HashConfig, db_config + assert_instance_of String, db_config.env_name + assert_instance_of String, db_config.spec_name + db_config.config.keys.each do |key| + assert_instance_of String, key + end + end + ensure + ActiveRecord::Base.configurations = @prev_configs + end + + def test_retrieve_connection + assert @handler.retrieve_connection(@spec_name) + end + + def test_active_connections? + assert_not_predicate @handler, :active_connections? + assert @handler.retrieve_connection(@spec_name) + assert_predicate @handler, :active_connections? + @handler.clear_active_connections! + assert_not_predicate @handler, :active_connections? + end + + def test_retrieve_connection_pool + assert_not_nil @handler.retrieve_connection_pool(@spec_name) + end + + def test_retrieve_connection_pool_with_invalid_id + assert_nil @handler.retrieve_connection_pool("foo") + end + + def test_connection_pools + assert_equal([@pool], @handler.connection_pools) + 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 + + def test_forked_child_doesnt_mangle_parent_connection + object_id = ActiveRecord::Base.connection.object_id + assert_predicate ActiveRecord::Base.connection, :active? + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + pid = fork { + rd.close + if ActiveRecord::Base.connection.active? + wr.write Marshal.dump ActiveRecord::Base.connection.object_id + end + wr.close + + exit # allow finalizers to run + } + + wr.close + + Process.waitpid pid + assert_not_equal object_id, Marshal.load(rd.read) + rd.close + + assert_equal 3, ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people") + end + + unless in_memory_db? + def test_forked_child_recovers_from_disconnected_parent + object_id = ActiveRecord::Base.connection.object_id + assert_predicate ActiveRecord::Base.connection, :active? + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + outer_pid = fork { + ActiveRecord::Base.connection.disconnect! + + pid = fork { + rd.close + if ActiveRecord::Base.connection.active? + pair = [ActiveRecord::Base.connection.object_id, + ActiveRecord::Base.connection.select_value("SELECT COUNT(*) FROM people")] + wr.write Marshal.dump pair + end + wr.close + + exit # allow finalizers to run + } + + Process.waitpid pid + } + + wr.close + + Process.waitpid outer_pid + child_id, child_count = Marshal.load(rd.read) + + assert_not_equal object_id, child_id + rd.close + + assert_equal 3, child_count + + # Outer connection is unaffected + assert_equal 6, ActiveRecord::Base.connection.select_value("SELECT 2 * COUNT(*) FROM people") + end + end + + def test_retrieve_connection_pool_copies_schema_cache_from_ancestor_pool + @pool.schema_cache = @pool.connection.schema_cache + @pool.schema_cache.add("posts") + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + pid = fork { + rd.close + pool = @handler.retrieve_connection_pool(@spec_name) + wr.write Marshal.dump pool.schema_cache.size + wr.close + exit! + } + + wr.close + + Process.waitpid pid + assert_equal @pool.schema_cache.size, Marshal.load(rd.read) + rd.close + end + + def test_pool_from_any_process_for_uses_most_recent_spec + skip unless current_adapter?(:SQLite3Adapter) + + file = Tempfile.new "lol.sqlite3" + + rd, wr = IO.pipe + rd.binmode + wr.binmode + + pid = fork do + ActiveRecord::Base.configurations["arunit"]["database"] = file.path + ActiveRecord::Base.establish_connection(:arunit) + + pid2 = fork do + wr.write ActiveRecord::Base.connection_config[:database] + wr.close + end + + Process.waitpid pid2 + end + + Process.waitpid pid + + wr.close + + assert_equal file.path, rd.read + + rd.close + ensure + if file + file.close + file.unlink + end + end + + def test_a_class_using_custom_pool_and_switching_back_to_primary + klass2 = Class.new(Base) { def self.name; "klass2"; end } + + assert_same klass2.connection, ActiveRecord::Base.connection + + pool = klass2.establish_connection(ActiveRecord::Base.connection_pool.spec.config) + assert_same klass2.connection, pool.connection + assert_not_same klass2.connection, ActiveRecord::Base.connection + + klass2.remove_connection + + assert_same klass2.connection, ActiveRecord::Base.connection + end + + def test_connection_specification_name_should_fallback_to_parent + klassA = Class.new(Base) + klassB = Class.new(klassA) + + assert_equal klassB.connection_specification_name, klassA.connection_specification_name + klassA.connection_specification_name = "readonly" + assert_equal "readonly", klassB.connection_specification_name + end + + def test_remove_connection_should_not_remove_parent + klass2 = Class.new(Base) { def self.name; "klass2"; end } + klass2.remove_connection + assert_not_nil ActiveRecord::Base.connection + assert_same klass2.connection, ActiveRecord::Base.connection + end + end + end + end +end diff --git a/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb new file mode 100644 index 0000000000..0b3fb82e12 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/connection_handlers_multi_db_test.rb @@ -0,0 +1,343 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/person" + +module ActiveRecord + module ConnectionAdapters + class ConnectionHandlersMultiDbTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :people + + def setup + @handlers = { writing: ConnectionHandler.new, reading: ConnectionHandler.new } + @rw_handler = @handlers[:writing] + @ro_handler = @handlers[:reading] + @spec_name = "primary" + @rw_pool = @handlers[:writing].establish_connection(ActiveRecord::Base.configurations["arunit"]) + @ro_pool = @handlers[:reading].establish_connection(ActiveRecord::Base.configurations["arunit"]) + end + + def teardown + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + + class MultiConnectionTestModel < ActiveRecord::Base + end + + def test_multiple_connection_handlers_works_in_a_threaded_environment + tf_writing = Tempfile.open "test_writing" + tf_reading = Tempfile.open "test_reading" + + MultiConnectionTestModel.connects_to database: { writing: { database: tf_writing.path, adapter: "sqlite3" }, reading: { database: tf_reading.path, adapter: "sqlite3" } } + + MultiConnectionTestModel.connection.execute("CREATE TABLE `test_1` (connection_role VARCHAR (255))") + MultiConnectionTestModel.connection.execute("INSERT INTO test_1 VALUES ('writing')") + + ActiveRecord::Base.connected_to(role: :reading) do + MultiConnectionTestModel.connection.execute("CREATE TABLE `test_1` (connection_role VARCHAR (255))") + MultiConnectionTestModel.connection.execute("INSERT INTO test_1 VALUES ('reading')") + end + + read_latch = Concurrent::CountDownLatch.new + write_latch = Concurrent::CountDownLatch.new + + MultiConnectionTestModel.connection + + thread = Thread.new do + MultiConnectionTestModel.connection + + write_latch.wait + assert_equal "writing", MultiConnectionTestModel.connection.select_value("SELECT connection_role from test_1") + read_latch.count_down + end + + ActiveRecord::Base.connected_to(role: :reading) do + write_latch.count_down + assert_equal "reading", MultiConnectionTestModel.connection.select_value("SELECT connection_role from test_1") + read_latch.wait + end + + thread.join + ensure + tf_reading.close + tf_reading.unlink + tf_writing.close + tf_writing.unlink + end + + unless in_memory_db? + def test_establish_connection_using_3_levels_config + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to(database: { writing: :primary, reading: :readonly }) + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:writing].retrieve_connection_pool("primary") + assert_equal "db/primary.sqlite3", pool.spec.config[:database] + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_via_handler + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" }, + "primary" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to(database: { writing: :primary, reading: :readonly }) + + ActiveRecord::Base.connected_to(role: :reading) do + @ro_handler = ActiveRecord::Base.connection_handler + assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:reading] + assert ActiveRecord::Base.connected_to?(role: :reading) + assert_not ActiveRecord::Base.connected_to?(role: :writing) + end + + ActiveRecord::Base.connected_to(role: :writing) do + assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:writing] + assert_not_equal @ro_handler, ActiveRecord::Base.connection_handler + assert ActiveRecord::Base.connected_to?(role: :writing) + assert_not ActiveRecord::Base.connected_to?(role: :reading) + end + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_with_database_url + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + previous_url, ENV["DATABASE_URL"] = ENV["DATABASE_URL"], "postgres://localhost/foo" + + ActiveRecord::Base.connected_to(database: { writing: "postgres://localhost/bar" }) do + assert ActiveRecord::Base.connected_to?(role: :writing) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:writing] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal({ adapter: "postgresql", database: "bar", host: "localhost" }, pool.spec.config) + end + ensure + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + ENV["DATABASE_URL"] = previous_url + end + + def test_switching_connections_with_database_config_hash + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + config = { adapter: "sqlite3", database: "db/readonly.sqlite3" } + + ActiveRecord::Base.connected_to(database: { writing: config }) do + assert ActiveRecord::Base.connected_to?(role: :writing) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:writing] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal(config, pool.spec.config) + end + ensure + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_switching_connections_with_database_and_role_raises + error = assert_raises(ArgumentError) do + ActiveRecord::Base.connected_to(database: :readonly, role: :writing) { } + end + assert_equal "connected_to can only accept a `database` or a `role` argument, but not both arguments.", error.message + end + + def test_switching_connections_without_database_and_role_raises + error = assert_raises(ArgumentError) do + ActiveRecord::Base.connected_to { } + end + assert_equal "must provide a `database` or a `role`.", error.message + end + + def test_switching_connections_with_database_symbol + previous_env, ENV["RAILS_ENV"] = ENV["RAILS_ENV"], "default_env" + + config = { + "default_env" => { + "readonly" => { adapter: "sqlite3", database: "db/readonly.sqlite3" }, + "primary" => { adapter: "sqlite3", database: "db/primary.sqlite3" } + } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connected_to(database: :readonly) do + assert ActiveRecord::Base.connected_to?(role: :readonly) + + handler = ActiveRecord::Base.connection_handler + assert_equal handler, ActiveRecord::Base.connection_handlers[:readonly] + + assert_not_nil pool = handler.retrieve_connection_pool("primary") + assert_equal(config["default_env"]["readonly"], pool.spec.config) + end + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + ENV["RAILS_ENV"] = previous_env + end + + def test_connects_to_with_single_configuration + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to database: { writing: :development } + + assert_equal 1, ActiveRecord::Base.connection_handlers.size + assert_equal ActiveRecord::Base.connection_handler, ActiveRecord::Base.connection_handlers[:writing] + assert ActiveRecord::Base.connected_to?(role: :writing) + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + end + + def test_connects_to_using_top_level_key_in_two_level_config + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + ActiveRecord::Base.connects_to database: { writing: :development, reading: :development_readonly } + + assert_not_nil pool = ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary") + assert_equal "db/readonly.sqlite3", pool.spec.config[:database] + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + end + + def test_connects_to_returns_array_of_established_connections + config = { + "development" => { "adapter" => "sqlite3", "database" => "db/primary.sqlite3" }, + "development_readonly" => { "adapter" => "sqlite3", "database" => "db/readonly.sqlite3" } + } + @prev_configs, ActiveRecord::Base.configurations = ActiveRecord::Base.configurations, config + + result = ActiveRecord::Base.connects_to database: { writing: :development, reading: :development_readonly } + + assert_equal( + [ + ActiveRecord::Base.connection_handlers[:writing].retrieve_connection_pool("primary"), + ActiveRecord::Base.connection_handlers[:reading].retrieve_connection_pool("primary") + ], + result + ) + ensure + ActiveRecord::Base.configurations = @prev_configs + ActiveRecord::Base.establish_connection(:arunit) + end + end + + def test_connection_pools + assert_equal([@rw_pool], @handlers[:writing].connection_pools) + assert_equal([@ro_pool], @handlers[:reading].connection_pools) + end + + def test_retrieve_connection + assert @rw_handler.retrieve_connection(@spec_name) + assert @ro_handler.retrieve_connection(@spec_name) + end + + def test_active_connections? + assert_not_predicate @rw_handler, :active_connections? + assert_not_predicate @ro_handler, :active_connections? + + assert @rw_handler.retrieve_connection(@spec_name) + assert @ro_handler.retrieve_connection(@spec_name) + + assert_predicate @rw_handler, :active_connections? + assert_predicate @ro_handler, :active_connections? + + @rw_handler.clear_active_connections! + assert_not_predicate @rw_handler, :active_connections? + + @ro_handler.clear_active_connections! + assert_not_predicate @ro_handler, :active_connections? + end + + def test_retrieve_connection_pool + assert_not_nil @rw_handler.retrieve_connection_pool(@spec_name) + assert_not_nil @ro_handler.retrieve_connection_pool(@spec_name) + end + + def test_retrieve_connection_pool_with_invalid_id + assert_nil @rw_handler.retrieve_connection_pool("foo") + assert_nil @ro_handler.retrieve_connection_pool("foo") + end + + def test_connection_handlers_are_per_thread_and_not_per_fiber + original_handlers = ActiveRecord::Base.connection_handlers + + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler, reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new } + + reading_handler = ActiveRecord::Base.connection_handlers[:reading] + + reading = ActiveRecord::Base.with_handler(:reading) do + Person.connection_handler + end + + assert_not_equal reading, ActiveRecord::Base.connection_handler + assert_equal reading, reading_handler + ensure + ActiveRecord::Base.connection_handlers = original_handlers + end + + def test_connection_handlers_swapping_connections_in_fiber + original_handlers = ActiveRecord::Base.connection_handlers + + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler, reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new } + + reading_handler = ActiveRecord::Base.connection_handlers[:reading] + + enum = Enumerator.new do |r| + r << ActiveRecord::Base.connection_handler + end + + reading = ActiveRecord::Base.with_handler(:reading) do + enum.next + end + + assert_equal reading, reading_handler + ensure + ActiveRecord::Base.connection_handlers = original_handlers + end + + def test_calling_connected_to_on_a_non_existent_handler_raises + error = assert_raises ArgumentError do + ActiveRecord::Base.connected_to(role: :reading) do + yield + end + end + + assert_equal "The reading role does not exist. Add it by establishing a connection with `connects_to` or use an existing role (writing).", error.message + 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..f81b73c344 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/connection_specification_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ConnectionSpecificationTest < ActiveRecord::TestCase + def test_dup_deep_copy_config + spec = ConnectionSpecification.new("primary", { 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..06c1c51724 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/merge_and_resolve_default_url_config_test.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class MergeAndResolveDefaultUrlConfigTest < ActiveRecord::TestCase + def setup + @previous_database_url = ENV.delete("DATABASE_URL") + @previous_rack_env = ENV.delete("RACK_ENV") + @previous_rails_env = ENV.delete("RAILS_ENV") + end + + teardown do + ENV["DATABASE_URL"] = @previous_database_url + ENV["RACK_ENV"] = @previous_rack_env + ENV["RAILS_ENV"] = @previous_rails_env + end + + def resolve_config(config) + configs = ActiveRecord::DatabaseConfigurations.new(config) + configs.to_h + end + + def resolve_spec(spec, config) + configs = ActiveRecord::DatabaseConfigurations.new(config) + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs) + resolver.resolve(spec, 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", "name" => "default_env" } + assert_equal expected, actual + end + + def test_resolver_with_database_uri_and_current_env_symbol_key_and_rails_env + ENV["DATABASE_URL"] = "postgres://localhost/foo" + ENV["RAILS_ENV"] = "foo" + + config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } + actual = resolve_spec(:foo, config) + expected = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost", "name" => "foo" } + assert_equal expected, actual + end + + def test_resolver_with_database_uri_and_current_env_symbol_key_and_rack_env + ENV["DATABASE_URL"] = "postgres://localhost/foo" + ENV["RACK_ENV"] = "foo" + + config = { "not_production" => { "adapter" => "not_postgres", "database" => "not_foo" } } + actual = resolve_spec(:foo, config) + expected = { "adapter" => "postgresql", "database" => "foo", "host" => "localhost", "name" => "foo" } + 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", "name" => "production" } + 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_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", "name" => "default_env" } + 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_nil actual["production"] + assert_nil actual["development"] + assert_nil actual["test"] + assert_nil actual[:default_env] + assert_nil actual[:production] + assert_nil actual[:development] + assert_nil actual[:test] + end + + def test_blank_with_database_url_with_rails_env + ENV["RAILS_ENV"] = "not_production" + ENV["DATABASE_URL"] = "postgres://localhost/foo" + + config = {} + actual = resolve_config(config) + expected = { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost" } + + assert_equal expected, actual["not_production"] + assert_nil actual["production"] + assert_nil actual["default_env"] + assert_nil actual["development"] + assert_nil actual["test"] + assert_nil actual[:default_env] + assert_nil actual[:not_production] + assert_nil actual[:production] + assert_nil actual[:development] + assert_nil actual[:test] + end + + def test_blank_with_database_url_with_rack_env + ENV["RACK_ENV"] = "not_production" + ENV["DATABASE_URL"] = "postgres://localhost/foo" + + config = {} + actual = resolve_config(config) + expected = { "adapter" => "postgresql", + "database" => "foo", + "host" => "localhost" } + + assert_equal expected, actual["not_production"] + assert_nil actual["production"] + assert_nil actual["default_env"] + assert_nil actual["development"] + assert_nil actual["test"] + assert_nil actual[:default_env] + assert_nil actual[:not_production] + assert_nil actual[:production] + assert_nil actual[:development] + assert_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..02e76ce146 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/mysql_type_lookup_test.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +if current_adapter?(:Mysql2Adapter) + module ActiveRecord + module ConnectionAdapters + class MysqlTypeLookupTest < ActiveRecord::TestCase + include ConnectionHelper + + setup do + @connection = ActiveRecord::Base.connection + end + + def teardown + reset_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_set_type_with_value_matching_other_type + assert_lookup_type :string, "SET('unicode', '8bit', 'none', 'time')" + end + + def test_enum_type_with_value_matching_other_type + assert_lookup_type :string, "ENUM('unicode', '8bit', 'none')" + 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.send(: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/schema_cache_test.rb b/activerecord/test/cases/connection_adapters/schema_cache_test.rb new file mode 100644 index 0000000000..67496381d1 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/schema_cache_test.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +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_yaml_dump_and_load + @cache.columns("posts") + @cache.columns_hash("posts") + @cache.data_sources("posts") + @cache.primary_keys("posts") + + new_cache = YAML.load(YAML.dump(@cache)) + assert_no_queries do + assert_equal 12, new_cache.columns("posts").size + assert_equal 12, new_cache.columns_hash("posts").size + assert new_cache.data_sources("posts") + assert_equal "id", new_cache.primary_keys("posts") + end + end + + def test_yaml_loads_5_1_dump + body = File.open(schema_dump_path).read + cache = YAML.load(body) + + assert_no_queries do + assert_equal 11, cache.columns("posts").size + assert_equal 11, cache.columns_hash("posts").size + assert cache.data_sources("posts") + assert_equal "id", cache.primary_keys("posts") + end + 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.data_sources("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.data_sources("posts") + @cache.primary_keys("posts") + + @cache = Marshal.load(Marshal.dump(@cache)) + + assert_no_queries do + assert_equal 12, @cache.columns("posts").size + assert_equal 12, @cache.columns_hash("posts").size + assert @cache.data_sources("posts") + assert_equal "id", @cache.primary_keys("posts") + end + end + + def test_data_source_exist + assert @cache.data_source_exists?("posts") + assert_not @cache.data_source_exists?("foo") + end + + def test_clear_data_source_cache + @cache.clear_data_source_cache!("posts") + end + + private + + def schema_dump_path + "test/assets/schema_dump_5_1.yml" + 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..1c79d776f0 --- /dev/null +++ b/activerecord/test/cases/connection_adapters/type_lookup_test.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "cases/helper" + +unless current_adapter?(:PostgreSQLAdapter) # PostgreSQL does not use type strings 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_bigint_limit + limit = @connection.send(:type_map).lookup("bigint").send(:_limit) + if current_adapter?(:OracleAdapter) + assert_equal 19, limit + else + assert_equal 8, limit + end + end + + def test_decimal_without_scale + if current_adapter?(:OracleAdapter) + { + decimal: %w{decimal(2) decimal(2,0) numeric(2) numeric(2,0)}, + integer: %w{number(2) number(2,0)} + } + else + { decimal: %w{decimal(2) decimal(2,0) numeric(2) numeric(2,0) number(2) number(2,0)} } + end.each do |expected_type, types| + types.each do |type| + cast_type = @connection.send(:type_map).lookup(type) + + assert_equal expected_type, cast_type.type + assert_equal 2, cast_type.cast(2.1) + end + end + end + + private + + def assert_lookup_type(type, lookup) + cast_type = @connection.send(: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..b9b5cc0e28 --- /dev/null +++ b/activerecord/test/cases/connection_management_test.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "cases/helper" +require "rack" + +module ActiveRecord + module ConnectionAdapters + class ConnectionManagementTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + 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 = middleware(@app) + + # make sure we have an active connection + assert ActiveRecord::Base.connection + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? + end + + def test_app_delegation + manager = middleware(@app) + + manager.call @env + assert_equal [@env], @app.calls + 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_not_predicate ActiveRecord::Base.connection_handler, :active_connections? + end + + def test_active_connections_are_not_cleared_on_body_close_during_transaction + ActiveRecord::Base.transaction do + _, _, body = @management.call(@env) + body.close + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? + end + end + + def test_connections_closed_if_exception + app = Class.new(App) { def call(env); raise NotImplementedError; end }.new + explosive = middleware(app) + assert_raises(NotImplementedError) { explosive.call(@env) } + assert_not_predicate ActiveRecord::Base.connection_handler, :active_connections? + end + + def test_connections_not_closed_if_exception_inside_transaction + ActiveRecord::Base.transaction do + app = Class.new(App) { def call(env); raise RuntimeError; end }.new + explosive = middleware(app) + assert_raises(RuntimeError) { explosive.call(@env) } + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? + end + end + + test "doesn't clear active connections when running in a test case" do + executor.wrap do + @management.call(@env) + assert_predicate ActiveRecord::Base.connection_handler, :active_connections? + end + end + + test "proxy is polite to its body and responds to it" do + body = Class.new(String) { def to_path; "/path"; end }.new + app = lambda { |_| [200, {}, body] } + response_body = middleware(app).call(@env)[2] + assert_respond_to response_body, :to_path + assert_equal "/path", response_body.to_path + end + + test "doesn't mutate the original response" do + original_response = [200, {}, "hi"] + app = lambda { |_| original_response } + middleware(app).call(@env)[2] + assert_equal "hi", original_response.last + end + + private + def executor + @executor ||= Class.new(ActiveSupport::Executor).tap do |exe| + ActiveRecord::QueryCache.install_executor_hooks(exe) + end + end + + def middleware(app) + lambda do |env| + a, b, c = executor.wrap { app.call(env) } + [a, b, Rack::BodyProxy.new(c) { }] + end + 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..a15ad9a45b --- /dev/null +++ b/activerecord/test/cases/connection_pool_test.rb @@ -0,0 +1,708 @@ +# frozen_string_literal: true + +require "cases/helper" +require "concurrent/atomic/count_down_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_predicate connection, :in_use? + + connection.close + assert_not_predicate connection, :in_use? + + assert_predicate 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_not_predicate pool, :active_connection? + main_thread = pool.connection + + assert_predicate pool, :active_connection? + + main_thread.close + + assert_not_predicate pool, :active_connection? + end + + def test_full_pool_exception + @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout + @pool.size.times { assert @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 @pool.num_waiting_in_queue == 1 + + connection = cs.first + connection.close + assert_equal connection, t.join.value + end + + def test_full_pool_blocking_shares_load_interlock + @pool.instance_variable_set(:@size, 1) + + load_interlock_latch = Concurrent::CountDownLatch.new + connection_latch = Concurrent::CountDownLatch.new + + able_to_get_connection = false + able_to_load = false + + thread_with_load_interlock = Thread.new do + ActiveSupport::Dependencies.interlock.running do + load_interlock_latch.count_down + connection_latch.wait + + @pool.with_connection do + able_to_get_connection = true + end + end + end + + thread_with_last_connection = Thread.new do + @pool.with_connection do + connection_latch.count_down + load_interlock_latch.wait + + ActiveSupport::Dependencies.interlock.loading do + able_to_load = true + end + end + end + + thread_with_load_interlock.join + thread_with_last_connection.join + + assert able_to_get_connection + assert able_to_load + 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 @pool.num_waiting_in_queue == 1 + + 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 = Concurrent::CountDownLatch.new + @pool.checkout + child = Thread.new do + @pool.checkout + @pool.checkout + ready.count_down + Thread.stop + end + ready.wait + + assert_equal 3, active_connections(@pool).size + + child.terminate + child.join + @pool.reap + + assert_equal 1, active_connections(@pool).size + ensure + @pool.connections.each { |conn| conn.close if conn.in_use? } + end + + def test_idle_timeout_configuration + @pool.disconnect! + spec = ActiveRecord::Base.connection_pool.spec + spec.config.merge!(idle_timeout: "0.02") + @pool = ConnectionPool.new(spec) + idle_conn = @pool.checkout + @pool.checkin(idle_conn) + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 0.01 + ) + + @pool.flush + assert_equal 1, @pool.connections.length + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 0.02 + ) + + @pool.flush + assert_equal 0, @pool.connections.length + end + + def test_disable_flush + @pool.disconnect! + spec = ActiveRecord::Base.connection_pool.spec + spec.config.merge!(idle_timeout: -5) + @pool = ConnectionPool.new(spec) + idle_conn = @pool.checkout + @pool.checkin(idle_conn) + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 1 + ) + + @pool.flush + assert_equal 1, @pool.connections.length + end + + def test_flush + idle_conn = @pool.checkout + recent_conn = @pool.checkout + active_conn = @pool.checkout + + @pool.checkin idle_conn + @pool.checkin recent_conn + + assert_equal 3, @pool.connections.length + + idle_conn.instance_variable_set( + :@idle_since, + Concurrent.monotonic_time - 1000 + ) + + @pool.flush(30) + + assert_equal 2, @pool.connections.length + + assert_equal [recent_conn, active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__) + ensure + @pool.checkin active_conn + end + + def test_flush_bang + idle_conn = @pool.checkout + recent_conn = @pool.checkout + active_conn = @pool.checkout + _dead_conn = Thread.new { @pool.checkout }.join + + @pool.checkin idle_conn + @pool.checkin recent_conn + + assert_equal 4, @pool.connections.length + + def idle_conn.seconds_idle + 1000 + end + + @pool.flush! + + assert_equal 1, @pool.connections.length + + assert_equal [active_conn].sort_by(&:__id__), @pool.connections.sort_by(&:__id__) + ensure + @pool.checkin active_conn + end + + def test_remove_connection + conn = @pool.checkout + assert_predicate conn, :in_use? + + length = @pool.connections.length + @pool.remove conn + assert_predicate 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_not_predicate @pool, :active_connection? + assert @pool.connection + assert_predicate @pool, :active_connection? + @pool.release_connection + assert_not_predicate @pool, :active_connection? + end + + def test_checkout_behaviour + pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + main_connection = pool.connection + assert_not_nil main_connection + threads = [] + 4.times do |i| + threads << Thread.new(i) do + thread_connection = pool.connection + assert_not_nil thread_connection + thread_connection.close + end + end + + threads.each(&:join) + + Thread.new do + assert pool.connection + pool.connection.close + end.join + end + + def test_checkout_order_is_lifo + conn1 = @pool.checkout + conn2 = @pool.checkout + @pool.checkin conn1 + @pool.checkin conn2 + assert_equal [conn2, conn1], 2.times.map { @pool.checkout } + end + + # The connection pool is "fair" if threads waiting for + # connections receive them in 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 if 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 @pool.num_waiting_in_queue == i + 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 @pool.num_waiting_in_queue == i + 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_restores_after_disconnect + pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + assert pool.automatic_reconnect + assert pool.connection + + pool.disconnect! + assert pool.connection + end + + def test_automatic_reconnect_can_be_disabled + pool = ConnectionPool.new ActiveRecord::Base.connection_pool.spec + 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) + + assert_raises(RuntimeError) do + anonymous.establish_connection + end + end + + class ConnectionTestModel < ActiveRecord::Base + end + + def test_connection_notification_is_called + payloads = [] + subscription = ActiveSupport::Notifications.subscribe("!connection.active_record") do |name, started, finished, unique_id, payload| + payloads << payload + end + ConnectionTestModel.establish_connection :arunit + + assert_equal [:config, :connection_id, :spec_name], payloads[0].keys.sort + assert_equal "ActiveRecord::ConnectionAdapters::ConnectionPoolTest::ConnectionTestModel", payloads[0][:spec_name] + ensure + ActiveSupport::Notifications.unsubscribe(subscription) if subscription + end + + def test_pool_sets_connection_schema_cache + connection = pool.checkout + schema_cache = SchemaCache.new connection + schema_cache.add(:posts) + pool.schema_cache = schema_cache + + pool.with_connection do |conn| + assert_not_same pool.schema_cache, conn.schema_cache + assert_equal pool.schema_cache.size, conn.schema_cache.size + assert_same pool.schema_cache.columns(:posts), conn.schema_cache.columns(:posts) + end + + pool.checkin connection + end + + def test_concurrent_connection_establishment + assert_operator @pool.connections.size, :<=, 1 + + all_threads_in_new_connection = Concurrent::CountDownLatch.new(@pool.size - @pool.connections.size) + all_go = Concurrent::CountDownLatch.new + + @pool.singleton_class.class_eval do + define_method(:new_connection) do + all_threads_in_new_connection.count_down + all_go.wait + super() + end + end + + connecting_threads = [] + @pool.size.times do + connecting_threads << Thread.new { @pool.checkout } + end + + begin + Timeout.timeout(5) do + # the kernel of the whole test is here, everything else is just scaffolding, + # this latch will not be released unless conn. pool allows for concurrent + # connection creation + all_threads_in_new_connection.wait + end + rescue Timeout::Error + flunk "pool unable to establish connections concurrently or implementation has " \ + "changed, this test then needs to patch a different :new_connection method" + ensure + # clean up the threads + all_go.count_down + connecting_threads.map(&:join) + end + end + + def test_non_bang_disconnect_and_clear_reloadable_connections_throw_exception_if_threads_dont_return_their_conns + Thread.report_on_exception, original_report_on_exception = false, Thread.report_on_exception + @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout + [:disconnect, :clear_reloadable_connections].each do |group_action_method| + @pool.with_connection do |connection| + assert_raises(ExclusiveConnectionTimeoutError) do + Thread.new { @pool.send(group_action_method) }.join + end + end + end + ensure + Thread.report_on_exception = original_report_on_exception + end + + def test_disconnect_and_clear_reloadable_connections_attempt_to_wait_for_threads_to_return_their_conns + [:disconnect, :disconnect!, :clear_reloadable_connections, :clear_reloadable_connections!].each do |group_action_method| + thread = timed_join_result = nil + @pool.with_connection do |connection| + thread = Thread.new { @pool.send(group_action_method) } + + # give the other `thread` some time to get stuck in `group_action_method` + timed_join_result = thread.join(0.3) + # thread.join # => `nil` means the other thread hasn't finished running and is still waiting for us to + # release our connection + assert_nil timed_join_result + + # assert that since this is within default timeout our connection hasn't been forcefully taken away from us + assert_predicate @pool, :active_connection? + end + ensure + thread.join if thread && !timed_join_result # clean up the other thread + end + end + + def test_bang_versions_of_disconnect_and_clear_reloadable_connections_if_unable_to_acquire_all_connections_proceed_anyway + @pool.checkout_timeout = 0.001 # no need to delay test suite by waiting the whole full default timeout + [:disconnect!, :clear_reloadable_connections!].each do |group_action_method| + @pool.with_connection do |connection| + Thread.new { @pool.send(group_action_method) }.join + # assert connection has been forcefully taken away from us + assert_not_predicate @pool, :active_connection? + + # make a new connection for with_connection to clean up + @pool.connection + end + end + end + + def test_disconnect_and_clear_reloadable_connections_are_able_to_preempt_other_waiting_threads + with_single_connection_pool do |pool| + [:disconnect, :disconnect!, :clear_reloadable_connections, :clear_reloadable_connections!].each do |group_action_method| + conn = pool.connection # drain the only available connection + second_thread_done = Concurrent::Event.new + + begin + # create a first_thread and let it get into the FIFO queue first + first_thread = Thread.new do + pool.with_connection { second_thread_done.wait } + end + + # wait for first_thread to get in queue + Thread.pass until pool.num_waiting_in_queue == 1 + + # create a different, later thread, that will attempt to do a "group action", + # but because of the group action semantics it should be able to preempt the + # first_thread when a connection is made available + second_thread = Thread.new do + pool.send(group_action_method) + second_thread_done.set + end + + # wait for second_thread to get in queue + Thread.pass until pool.num_waiting_in_queue == 2 + + # return the only available connection + pool.checkin(conn) + + # if the second_thread is not able to preempt the first_thread, + # they will temporarily (until either of them timeouts with ConnectionTimeoutError) + # deadlock and a join(2) timeout will be reached + assert second_thread.join(2), "#{group_action_method} is not able to preempt other waiting threads" + + ensure + # post test clean up + failed = !second_thread_done.set? + + if failed + second_thread_done.set + + first_thread.join(2) + second_thread.join(2) + end + + first_thread.join(10) || raise("first_thread got stuck") + second_thread.join(10) || raise("second_thread got stuck") + end + end + end + end + + def test_clear_reloadable_connections_creates_new_connections_for_waiting_threads_if_necessary + with_single_connection_pool do |pool| + conn = pool.connection # drain the only available connection + def conn.requires_reloading? # make sure it gets removed from the pool by clear_reloadable_connections + true + end + + stuck_thread = Thread.new do + pool.with_connection { } + end + + # wait for stuck_thread to get in queue + Thread.pass until pool.num_waiting_in_queue == 1 + + pool.clear_reloadable_connections + + unless stuck_thread.join(2) + flunk "clear_reloadable_connections must not let other connection waiting threads get stuck in queue" + end + + assert_equal 0, pool.num_waiting_in_queue + end + end + + def test_connection_pool_stat + with_single_connection_pool do |pool| + pool.with_connection do |connection| + stats = pool.stat + assert_equal({ size: 1, connections: 1, busy: 1, dead: 0, idle: 0, waiting: 0, checkout_timeout: 5 }, stats) + end + + stats = pool.stat + assert_equal({ size: 1, connections: 1, busy: 0, dead: 0, idle: 1, waiting: 0, checkout_timeout: 5 }, stats) + + Thread.new do + pool.checkout + Thread.current.kill + end.join + + stats = pool.stat + assert_equal({ size: 1, connections: 1, busy: 0, dead: 1, idle: 0, waiting: 0, checkout_timeout: 5 }, stats) + end + end + + private + def with_single_connection_pool + one_conn_spec = ActiveRecord::Base.connection_pool.spec.dup + one_conn_spec.config[:pool] = 1 # this is safe to do, because .dupped ConnectionSpecification also auto-dups its config + yield(pool = ConnectionPool.new(one_conn_spec)) + ensure + pool.disconnect! if pool + 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..72be14f507 --- /dev/null +++ b/activerecord/test/cases/connection_specification/resolver_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class ConnectionSpecification + class ResolverTest < ActiveRecord::TestCase + def resolve(spec, config = {}) + configs = ActiveRecord::DatabaseConfigurations.new(config) + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs) + resolver.resolve(spec, spec) + end + + def spec(spec, config = {}) + configs = ActiveRecord::DatabaseConfigurations.new(config) + resolver = ConnectionAdapters::ConnectionSpecification::Resolver.new(configs) + resolver.spec(spec) + end + + def test_url_invalid_adapter + error = assert_raises(LoadError) do + spec "ridiculous://foo?encoding=utf8" + end + + assert_match "Could not load the 'ridiculous' Active Record adapter. Ensure that the adapter is spelled correctly in config/database.yml and that you've added the necessary adapter gem to your Gemfile.", 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", + "name" => "production" }, spec) + end + + def test_url_sub_key + spec = resolve :production, "production" => { "url" => "abstract://foo?encoding=utf8" } + assert_equal({ + "adapter" => "abstract", + "host" => "foo", + "encoding" => "utf8", + "name" => "production" }, 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", + "name" => "production" }, 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_missing_scheme + spec = resolve "foo" + assert_equal({ + "database" => "foo" }, 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", + "name" => "production" }, spec) + end + + def test_spec_name_on_key_lookup + spec = spec(:readonly, "readonly" => { "adapter" => "sqlite3" }) + assert_equal "readonly", spec.name + end + + def test_spec_name_with_inline_config + spec = spec("adapter" => "sqlite3") + assert_equal "primary", spec.name, "should default to primary id" + 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..36e3d543cd --- /dev/null +++ b/activerecord/test/cases/core_test.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/person" +require "models/topic" +require "pp" + +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_instance_with_non_primary_key_id_attribute + topic = topics(:first).becomes(TitlePrimaryKeyTopic) + assert_match(/id: 1/, topic.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 + #<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 + #<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 + + def test_pretty_print_overridden_by_inspect + subtopic = Class.new(Topic) do + def inspect + "inspecting topic" + end + end + actual = +"" + PP.pp(subtopic.new, StringIO.new(actual)) + assert_equal "inspecting topic\n", actual + end + + def test_pretty_print_with_non_primary_key_id_attribute + topic = topics(:first).becomes(TitlePrimaryKeyTopic) + actual = +"" + PP.pp(topic, StringIO.new(actual)) + assert_match(/id: 1/, actual) + 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..99d286dc52 --- /dev/null +++ b/activerecord/test/cases/counter_cache_test.rb @@ -0,0 +1,367 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/car" +require "models/aircraft" +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 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 + + test "counters are updated both in memory and in the database on create" do + car = Car.new(engines_count: 0) + car.engines = [Engine.new, Engine.new] + car.save! + + assert_equal 2, car.engines_count + assert_equal 2, car.reload.engines_count + end + + test "counter caches are updated in memory when the default value is nil" do + car = Car.new(engines_count: nil) + car.engines = [Engine.new, Engine.new] + car.save! + + assert_equal 2, car.engines_count + assert_equal 2, car.reload.engines_count + end + + test "update counters in a polymorphic relationship" do + aircraft = Aircraft.create! + + assert_difference "aircraft.reload.wheels_count" do + aircraft.wheels << Wheel.create! + end + + assert_difference "aircraft.reload.wheels_count", -1 do + aircraft.wheels.first.destroy + end + end + + test "update counters doesn't touch timestamps by default" do + @topic.update_column :updated_at, 5.minutes.ago + previously_updated_at = @topic.updated_at + + Topic.update_counters(@topic.id, replies_count: -1) + + assert_equal previously_updated_at, @topic.updated_at + end + + test "update counters doesn't touch timestamps with touch: []" do + @topic.update_column :updated_at, 5.minutes.ago + previously_updated_at = @topic.updated_at + + Topic.update_counters(@topic.id, replies_count: -1, touch: []) + + assert_equal previously_updated_at, @topic.updated_at + end + + test "update counters with touch: true" do + assert_touching @topic, :updated_at do + Topic.update_counters(@topic.id, replies_count: -1, touch: true) + end + end + + test "update counters of multiple records with touch: true" do + t1, t2 = topics(:first, :second) + + assert_touching t1, :updated_at do + assert_difference ["t1.reload.replies_count", "t2.reload.replies_count"], 2 do + Topic.update_counters([t1.id, t2.id], replies_count: 2, touch: true) + end + end + end + + test "update multiple counters with touch: true" do + assert_touching @topic, :updated_at do + Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: true) + end + end + + test "reset counters with touch: true" do + assert_touching @topic, :updated_at do + Topic.reset_counters(@topic.id, :replies, touch: true) + end + end + + test "reset multiple counters with touch: true" do + assert_touching @topic, :updated_at do + Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1) + Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: true) + end + end + + test "increment counters with touch: true" do + assert_touching @topic, :updated_at do + Topic.increment_counter(:replies_count, @topic.id, touch: true) + end + end + + test "decrement counters with touch: true" do + assert_touching @topic, :updated_at do + Topic.decrement_counter(:replies_count, @topic.id, touch: true) + end + end + + test "update counters with touch: :written_on" do + assert_touching @topic, :updated_at, :written_on do + Topic.update_counters(@topic.id, replies_count: -1, touch: :written_on) + end + end + + test "update multiple counters with touch: :written_on" do + assert_touching @topic, :updated_at, :written_on do + Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: :written_on) + end + end + + test "reset counters with touch: :written_on" do + assert_touching @topic, :updated_at, :written_on do + Topic.reset_counters(@topic.id, :replies, touch: :written_on) + end + end + + test "reset multiple counters with touch: :written_on" do + assert_touching @topic, :updated_at, :written_on do + Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1) + Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: :written_on) + end + end + + test "increment counters with touch: :written_on" do + assert_touching @topic, :updated_at, :written_on do + Topic.increment_counter(:replies_count, @topic.id, touch: :written_on) + end + end + + test "decrement counters with touch: :written_on" do + assert_touching @topic, :updated_at, :written_on do + Topic.decrement_counter(:replies_count, @topic.id, touch: :written_on) + end + end + + test "update counters with touch: %i( updated_at written_on )" do + assert_touching @topic, :updated_at, :written_on do + Topic.update_counters(@topic.id, replies_count: -1, touch: %i( updated_at written_on )) + end + end + + test "update multiple counters with touch: %i( updated_at written_on )" do + assert_touching @topic, :updated_at, :written_on do + Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: %i( updated_at written_on )) + end + end + + test "reset counters with touch: %i( updated_at written_on )" do + assert_touching @topic, :updated_at, :written_on do + Topic.reset_counters(@topic.id, :replies, touch: %i( updated_at written_on )) + end + end + + test "reset multiple counters with touch: %i( updated_at written_on )" do + assert_touching @topic, :updated_at, :written_on do + Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1) + Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: %i( updated_at written_on )) + end + end + + test "increment counters with touch: %i( updated_at written_on )" do + assert_touching @topic, :updated_at, :written_on do + Topic.increment_counter(:replies_count, @topic.id, touch: %i( updated_at written_on )) + end + end + + test "decrement counters with touch: %i( updated_at written_on )" do + assert_touching @topic, :updated_at, :written_on do + Topic.decrement_counter(:replies_count, @topic.id, touch: %i( updated_at written_on )) + end + end + + private + def assert_touching(record, *attributes) + record.update_columns attributes.map { |attr| [ attr, 5.minutes.ago ] }.to_h + touch_times = attributes.map { |attr| [ attr, record.public_send(attr) ] }.to_h + + yield + + touch_times.each do |attr, previous_touch_time| + assert_operator previous_touch_time, :<, record.reload.public_send(attr) + 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..f52b26e9ec --- /dev/null +++ b/activerecord/test/cases/custom_locking_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/person" + +module ActiveRecord + class CustomLockingTest < ActiveRecord::TestCase + fixtures :people + + def test_custom_lock + if current_adapter?(: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..1c934602ec --- /dev/null +++ b/activerecord/test/cases/database_statements_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "cases/helper" + +class DatabaseStatementsTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + end + + unless current_adapter?(:OracleAdapter) + def test_exec_insert + result = @connection.exec_insert("INSERT INTO accounts (firm_id,credit_limit) VALUES (42,5000)", nil, []) + assert_not_nil @connection.send(:last_inserted_id, result) + end + end + + def test_insert_should_return_the_inserted_id + assert_not_nil return_the_inserted_id(method: :insert) + end + + def test_create_should_return_the_inserted_id + assert_not_nil return_the_inserted_id(method: :create) + end + + private + + def return_the_inserted_id(method:) + # 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) + @connection.send(method, "INSERT INTO accounts (id, firm_id,credit_limit) VALUES (accounts_seq.nextval,42,5000)", nil, :id, id_value, sequence_name) + else + @connection.send(method, "INSERT INTO accounts (firm_id,credit_limit) VALUES (42,5000)") + end + end +end diff --git a/activerecord/test/cases/date_test.rb b/activerecord/test/cases/date_test.rb new file mode 100644 index 0000000000..9f412cdb63 --- /dev/null +++ b/activerecord/test/cases/date_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +class DateTest < ActiveRecord::TestCase + def test_date_with_time_value + time_value = Time.new(2016, 05, 11, 19, 0, 0) + topic = Topic.create(last_read: time_value) + assert_equal topic, Topic.find_by(last_read: time_value) + end + + def test_date_with_string_value + string_value = "2016-05-11 19:00:00" + topic = Topic.create(last_read: string_value) + assert_equal topic, Topic.find_by(last_read: string_value) + end + + 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/date_time_precision_test.rb b/activerecord/test/cases/date_time_precision_test.rb new file mode 100644 index 0000000000..e64a8372d0 --- /dev/null +++ b/activerecord/test/cases/date_time_precision_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +if subsecond_precision_supported? + class DateTimePrecisionTest < ActiveRecord::TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + class Foo < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + Foo.reset_column_information + end + + teardown do + @connection.drop_table :foos, if_exists: true + end + + def test_datetime_data_type_with_precision + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :created_at, :datetime, precision: 0 + @connection.add_column :foos, :updated_at, :datetime, precision: 5 + assert_equal 0, Foo.columns_hash["created_at"].precision + assert_equal 5, Foo.columns_hash["updated_at"].precision + end + + def test_datetime_precision_is_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :created_at, :datetime, precision: 0 + @connection.add_column :foos, :updated_at, :datetime, precision: 6 + + time = ::Time.now.change(nsec: 123456789) + foo = Foo.new(created_at: time, updated_at: time) + + assert_equal 0, foo.created_at.nsec + assert_equal 123456000, foo.updated_at.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.created_at.nsec + assert_equal 123456000, foo.updated_at.nsec + end + + def test_timestamps_helper_with_custom_precision + @connection.create_table(:foos, force: true) do |t| + t.timestamps precision: 4 + end + assert_equal 4, Foo.columns_hash["created_at"].precision + assert_equal 4, Foo.columns_hash["updated_at"].precision + end + + def test_passing_precision_to_datetime_does_not_set_limit + @connection.create_table(:foos, force: true) do |t| + t.timestamps precision: 4 + end + assert_nil Foo.columns_hash["created_at"].limit + assert_nil Foo.columns_hash["updated_at"].limit + end + + def test_invalid_datetime_precision_raises_error + assert_raises ActiveRecord::ActiveRecordError do + @connection.create_table(:foos, force: true) do |t| + t.timestamps precision: 7 + end + end + end + + def test_formatting_datetime_according_to_precision + @connection.create_table(:foos, force: true) do |t| + t.datetime :created_at, precision: 0 + t.datetime :updated_at, precision: 4 + end + date = ::Time.utc(2014, 8, 17, 12, 30, 0, 999999) + Foo.create!(created_at: date, updated_at: date) + assert foo = Foo.find_by(created_at: date) + assert_equal 1, Foo.where(updated_at: date).count + assert_equal date.to_s, foo.created_at.to_s + assert_equal date.to_s, foo.updated_at.to_s + assert_equal 000000, foo.created_at.usec + assert_equal 999900, foo.updated_at.usec + end + + def test_schema_dump_includes_datetime_precision + @connection.create_table(:foos, force: true) do |t| + t.timestamps precision: 6 + end + output = dump_table_schema("foos") + assert_match %r{t\.datetime\s+"created_at",\s+precision: 6,\s+null: false$}, output + assert_match %r{t\.datetime\s+"updated_at",\s+precision: 6,\s+null: false$}, output + end + + if current_adapter?(:PostgreSQLAdapter, :SQLServerAdapter) + def test_datetime_precision_with_zero_should_be_dumped + @connection.create_table(:foos, force: true) do |t| + t.timestamps precision: 0 + end + output = dump_table_schema("foos") + assert_match %r{t\.datetime\s+"created_at",\s+precision: 0,\s+null: false$}, output + assert_match %r{t\.datetime\s+"updated_at",\s+precision: 0,\s+null: false$}, output + end + end + 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..b5f35aff0e --- /dev/null +++ b/activerecord/test/cases/date_time_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/task" + +class DateTimeTest < ActiveRecord::TestCase + include InTimeZone + + 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_bad_date_time_with_timezone + in_time_zone "Pacific Time (US & Canada)" do + task = Task.new + task.starting = "2014-07-01T24:59:59GMT" + assert_nil task.starting + end + 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 + + def test_assign_in_local_timezone + now = DateTime.civil(2017, 3, 1, 12, 0, 0) + with_timezone_config default: :local do + task = Task.new starting: now + assert_equal now, task.starting + end + end + + def test_date_time_with_string_value_with_subsecond_precision + skip unless subsecond_precision_supported? + string_value = "2017-07-04 14:19:00.5" + topic = Topic.create(written_on: string_value) + assert_equal topic, Topic.find_by(written_on: string_value) + end + + def test_date_time_with_string_value_with_non_iso_format + string_value = "04/07/2017 2:19pm" + topic = Topic.create(written_on: string_value) + assert_equal topic, Topic.find_by(written_on: string_value) + end +end diff --git a/activerecord/test/cases/defaults_test.rb b/activerecord/test/cases/defaults_test.rb new file mode 100644 index 0000000000..5d02e59ef6 --- /dev/null +++ b/activerecord/test/cases/defaults_test.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" +require "models/default" +require "models/entrant" + +class DefaultTest < ActiveRecord::TestCase + def test_nil_defaults_for_not_null_columns + %w(id name course_id).each do |name| + column = Entrant.columns_hash[name] + assert_not column.null, "#{name} column should be NOT NULL" + assert_not column.default, "#{name} column should be DEFAULT 'nil'" + end + end + + if current_adapter?(:PostgreSQLAdapter) + def test_multiline_default_text + record = Default.new + # older postgres versions represent the default with escapes ("\\012" for a newline) + assert("--- []\n\n" == record.multiline_default || "--- []\\012\\012" == record.multiline_default) + end + end +end + +class DefaultNumbersTest < ActiveRecord::TestCase + class DefaultNumber < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :default_numbers do |t| + t.integer :positive_integer, default: 7 + t.integer :negative_integer, default: -5 + t.decimal :decimal_number, default: "2.78", precision: 5, scale: 2 + end + end + + teardown do + @connection.drop_table :default_numbers, if_exists: true + end + + def test_default_positive_integer + record = DefaultNumber.new + assert_equal 7, record.positive_integer + assert_equal "7", record.positive_integer_before_type_cast + end + + def test_default_negative_integer + record = DefaultNumber.new + assert_equal (-5), record.negative_integer + assert_equal "-5", record.negative_integer_before_type_cast + end + + def test_default_decimal_number + record = DefaultNumber.new + assert_equal BigDecimal("2.78"), record.decimal_number + assert_equal "2.78", record.decimal_number_before_type_cast + 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?(:PostgreSQLAdapter) + class PostgresqlDefaultExpressionTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + test "schema dump includes default expression" do + output = dump_table_schema("defaults") + if ActiveRecord::Base.connection.postgresql_version >= 100000 + assert_match %r/t\.date\s+"modified_date",\s+default: -> { "CURRENT_DATE" }/, output + assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "CURRENT_TIMESTAMP" }/, output + else + assert_match %r/t\.date\s+"modified_date",\s+default: -> { "\('now'::text\)::date" }/, output + assert_match %r/t\.datetime\s+"modified_time",\s+default: -> { "now\(\)" }/, output + end + assert_match %r/t\.date\s+"modified_date_function",\s+default: -> { "now\(\)" }/, output + assert_match %r/t\.datetime\s+"modified_time_function",\s+default: -> { "now\(\)" }/, output + end + end +end + +if current_adapter?(:Mysql2Adapter) + class MysqlDefaultExpressionTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + if supports_default_expression? + test "schema dump includes default expression" do + output = dump_table_schema("defaults") + assert_match %r/t\.binary\s+"uuid",\s+limit: 36,\s+default: -> { "\(uuid\(\)\)" }/i, output + end + end + + if subsecond_precision_supported? + test "schema dump datetime includes default expression" do + output = dump_table_schema("datetime_defaults") + assert_match %r/t\.datetime\s+"modified_datetime",\s+default: -> { "CURRENT_TIMESTAMP(?:\(\))?" }/i, output + end + + test "schema dump datetime includes precise default expression" do + output = dump_table_schema("datetime_defaults") + assert_match %r/t\.datetime\s+"precise_datetime",.+default: -> { "CURRENT_TIMESTAMP\(6\)" }/i, output + end + + test "schema dump timestamp includes default expression" do + output = dump_table_schema("timestamp_defaults") + assert_match %r/t\.timestamp\s+"modified_timestamp",\s+default: -> { "CURRENT_TIMESTAMP(?:\(\))?" }/i, output + end + + test "schema dump timestamp includes precise default expression" do + output = dump_table_schema("timestamp_defaults") + assert_match %r/t\.timestamp\s+"precise_timestamp",.+default: -> { "CURRENT_TIMESTAMP\(6\)" }/i, output + end + + test "schema dump timestamp without default expression" do + output = dump_table_schema("timestamp_defaults") + assert_match %r/t\.timestamp\s+"nullable_timestamp"$/, output + end + end + end + + class DefaultsTestWithoutTransactionalFixtures < ActiveRecord::TestCase + # ActiveRecord::Base#create! (and #save and other related methods) will + # open a new transaction. When in transactional tests 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 tests here. + self.use_transactional_tests = 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 + + # Strict mode controls how MySQL handles invalid or missing values + # in data-change statements such as INSERT or UPDATE. A value can be + # invalid for several reasons. For example, it might have the wrong + # data type for the column, or it might be out of range. A value is + # missing when a new row to be inserted does not contain a value for + # a non-NULL column that has no explicit DEFAULT clause in its definition. + # (For a NULL column, NULL is inserted if the value is missing.) + # + # If strict mode is not in effect, MySQL inserts adjusted values for + # invalid or missing values and produces warnings. In strict mode, + # you can produce this behavior by using INSERT IGNORE or UPDATE IGNORE. + # + # https://dev.mysql.com/doc/refman/5.7/en/sql-mode.html#sql-mode-strict + def test_mysql_not_null_defaults_non_strict + using_strict(false) do + with_mysql_not_null_table do |klass| + record = klass.new + assert_nil record.non_null_integer + assert_nil record.non_null_string + assert_nil record.non_null_text + assert_nil record.non_null_blob + + record.save! + record.reload + + assert_equal 0, record.non_null_integer + assert_equal "", record.non_null_string + assert_equal "", record.non_null_text + assert_equal "", record.non_null_blob + end + end + end + + def test_mysql_not_null_defaults_strict + using_strict(true) do + with_mysql_not_null_table do |klass| + record = klass.new + assert_nil record.non_null_integer + assert_nil record.non_null_string + assert_nil record.non_null_text + assert_nil record.non_null_blob + + assert_raises(ActiveRecord::NotNullViolation) { klass.create } + end + end + end + + def with_mysql_not_null_table + klass = Class.new(ActiveRecord::Base) + klass.table_name = "test_mysql_not_null_defaults" + klass.connection.create_table klass.table_name do |t| + t.integer :non_null_integer, null: false + t.string :non_null_string, null: false + t.text :non_null_text, null: false + t.blob :non_null_blob, null: false + end + + yield klass + ensure + klass.connection.drop_table(klass.table_name) rescue nil + 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..dfd74bfcb4 --- /dev/null +++ b/activerecord/test/cases/dirty_test.rb @@ -0,0 +1,922 @@ +# frozen_string_literal: true + +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" +require "models/numeric_data" + +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_equal false, pirate.catchphrase_changed? + assert_equal false, pirate.non_validated_parrot_id_changed? + + # Change catchphrase. + pirate.catchphrase = "arrr" + assert_predicate pirate, :catchphrase_changed? + assert_nil pirate.catchphrase_was + assert_equal [nil, "arrr"], pirate.catchphrase_change + + # Saved - no changes. + pirate.save! + assert_not_predicate pirate, :catchphrase_changed? + assert_nil pirate.catchphrase_change + + # Same value - no changes. + pirate.catchphrase = "arrr" + assert_not_predicate 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_not_predicate pirate, :created_on_changed? + assert_nil pirate.created_on_change + + # Saved - no changes. + pirate.catchphrase = "arrrr, time zone!!" + pirate.save! + assert_not_predicate 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_predicate 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_not_predicate 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_not_predicate 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_not_predicate pirate, :created_on_changed? + assert_nil pirate.created_on_change + + # Saved - no changes. + pirate.catchphrase = "arrrr, time zone!!" + pirate.save! + assert_not_predicate 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_predicate 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_not_predicate pirate, :created_on_changed? + assert_nil pirate.created_on_change + + # Saved - no changes. + pirate.catchphrase = "arrrr, time zone!!" + pirate.save! + assert_not_predicate 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_predicate 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_not_predicate parrot, :title_changed? + assert_nil parrot.title_change + + parrot.name = "Sam" + assert_predicate parrot, :title_changed? + assert_nil parrot.title_was + assert_equal parrot.name_change, parrot.title_change + 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_not_predicate 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_not_predicate 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_not_predicate 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_not_predicate 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_not_predicate 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_not_predicate pirate, :changed? + + pirate.parrot_id = "0" + assert_not_predicate 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_not_predicate pirate, :changed? + + pirate.parrot_id = 0 + assert_not_predicate pirate, :changed? + end + + def test_float_zero_to_string_zero_not_marked_as_changed + data = NumericData.new temperature: 0.0 + data.save! + + assert_not_predicate 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_predicate 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_predicate 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_predicate 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_not_predicate pirate, :changed? + assert_equal [], pirate.changed + assert_equal Hash.new, pirate.changes + + pirate.catchphrase = "arrr" + assert_predicate pirate, :changed? + assert_nil pirate.catchphrase_was + assert_equal %w(catchphrase), pirate.changed + assert_equal({ "catchphrase" => [nil, "arrr"] }, pirate.changes) + + pirate.save + assert_not_predicate pirate, :changed? + assert_equal [], pirate.changed + assert_equal Hash.new, pirate.changes + end + + def test_attribute_will_change! + pirate = Pirate.create!(catchphrase: "arr") + + assert_not_predicate pirate, :catchphrase_changed? + assert pirate.catchphrase_will_change! + assert_predicate pirate, :catchphrase_changed? + assert_equal ["arr", "arr"], pirate.catchphrase_change + + pirate.catchphrase << " matey!" + assert_predicate pirate, :catchphrase_changed? + assert_equal ["arr", "arr matey!"], pirate.catchphrase_change + end + + def test_virtual_attribute_will_change + parrot = Parrot.create!(name: "Ruby") + parrot.send(:attribute_will_change!, :cancel_save_from_callback) + assert_predicate parrot, :has_changes_to_save? + end + + def test_association_assignment_changes_foreign_key + pirate = Pirate.create!(catchphrase: "jarl") + pirate.parrot = Parrot.create!(name: "Lorre") + assert_predicate pirate, :changed? + assert_equal %w(parrot_id), pirate.changed + end + + def test_attribute_should_be_compared_with_type_cast + topic = Topic.new + assert_predicate topic, :approved? + assert_not_predicate topic, :approved_changed? + + # Coming from web form. + params = { topic: { approved: 1 } } + # In the controller. + topic.attributes = params[:topic] + assert_predicate topic, :approved? + assert_not_predicate 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_no_queries { 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") + + with_partial_writes Person, false do + assert_queries(2) { 2.times { person.save! } } + Person.where(id: person.id).update_all(first_name: "baz") + end + + old_lock_version = person.lock_version + + with_partial_writes Person, true do + assert_no_queries { 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_not 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_predicate pirate, :changed? + pirate.reload + assert_not_predicate 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_predicate pirate, :catchphrase_changed? + assert_not_predicate 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_predicate pirate, :changed? + pirate.catchphrase = phrase + assert_not_predicate 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_predicate pirate, :changed? + end + assert_predicate pirate, :changed? + pirate.catchphrase = phrase + assert_not_predicate 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_predicate pirate, :changed? + assert_predicate pirate, :parrot_id_changed? + assert_not_predicate pirate, :catchphrase_changed? + + pirate.parrot_id = nil + assert_not_predicate pirate, :changed? + assert_not_predicate pirate, :parrot_id_changed? + assert_not_predicate 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_predicate topic, :changed? + + topic.content[:b] = "b" + + assert_predicate topic, :changed? + + topic.save! + + assert_not_predicate 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 + travel(1.second) do + topic.content[:hello] = "world" + topic.save! + end + + 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 = Topic.create!(author_name: "Bill", content: { a: "a" }) + topic = Topic.select("id, author_name").find(topic.id) + topic.update_columns author_name: "John" + assert_not_nil topic.reload.content + end + end + + def test_changes_to_save_should_not_mutate_array_of_hashes + topic = Topic.new(author_name: "Bill", content: [{ a: "a" }]) + + topic.changes_to_save + + assert_equal [{ a: "a" }], topic.content + 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_not 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_includes pirate.previous_changes, "updated_on" + assert_includes pirate.previous_changes, "created_on" + assert_not pirate.previous_changes.key?("parrot_id") + + pirate.catchphrase = "Yar!!" + pirate.reload + assert_equal Hash.new, pirate.previous_changes + + pirate = Pirate.find_by_catchphrase("arrr") + + travel(1.second) + + 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_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") + + pirate = Pirate.find_by_catchphrase("Me Maties!") + + travel(1.second) + + 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_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") + + travel(1.second) + + 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_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") + + travel(1.second) + + 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_not pirate.previous_changes.key?("parrot_id") + assert_not pirate.previous_changes.key?("created_on") + end + + 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 + ActiveRecord::Base.clear_cache! + end + + def test_datetime_attribute_can_be_updated_with_fractional_seconds + skip "Fractional seconds are not supported" unless subsecond_precision_supported? + 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_not_predicate 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.include?("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 "in place mutation detection" do + pirate = Pirate.create!(catchphrase: "arrrr") + pirate.catchphrase << " matey!" + + assert_predicate pirate, :catchphrase_changed? + expected_changes = { + "catchphrase" => ["arrrr", "arrrr matey!"] + } + assert_equal(expected_changes, pirate.changes) + assert_equal("arrrr", pirate.catchphrase_was) + assert pirate.catchphrase_changed?(from: "arrrr") + assert_not pirate.catchphrase_changed?(from: "anything else") + assert_includes pirate.changed_attributes, :catchphrase + + pirate.save! + pirate.reload + + assert_equal "arrrr matey!", pirate.catchphrase + assert_not_predicate pirate, :changed? + end + + test "in place mutation for binary" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = :binaries + serialize :data + end + + binary = klass.create!(data: "\\\\foo") + + assert_not_predicate binary, :changed? + + binary.data = binary.data.dup + + assert_not_predicate binary, :changed? + + binary = klass.last + + assert_not_predicate binary, :changed? + + binary.data << "bar" + + assert_predicate binary, :changed? + end + + test "changes is correct for subclass" do + foo = Class.new(Pirate) do + def catchphrase + super.upcase + end + end + + pirate = foo.create!(catchphrase: "arrrr") + + new_catchphrase = "arrrr matey!" + + pirate.catchphrase = new_catchphrase + assert_predicate pirate, :catchphrase_changed? + + expected_changes = { + "catchphrase" => ["arrrr", new_catchphrase] + } + + assert_equal new_catchphrase.upcase, pirate.catchphrase + assert_equal expected_changes, pirate.changes + end + + test "changes is correct if override attribute reader" do + pirate = Pirate.create!(catchphrase: "arrrr") + def pirate.catchphrase + super.upcase + end + + new_catchphrase = "arrrr matey!" + + pirate.catchphrase = new_catchphrase + assert_predicate pirate, :catchphrase_changed? + + expected_changes = { + "catchphrase" => ["arrrr", new_catchphrase] + } + + assert_equal new_catchphrase.upcase, pirate.catchphrase + assert_equal expected_changes, pirate.changes + end + + test "attribute_changed? doesn't compute in-place changes for unrelated attributes" do + test_type_class = Class.new(ActiveRecord::Type::Value) do + define_method(:changed_in_place?) do |*| + raise + end + end + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + attribute :foo, test_type_class.new + end + + model = klass.new(first_name: "Jim") + assert_predicate model, :first_name_changed? + end + + test "attribute_will_change! doesn't try to save non-persistable attributes" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + attribute :non_persisted_attribute, :string + end + + record = klass.new(first_name: "Sean") + record.non_persisted_attribute_will_change! + + assert_predicate record, :non_persisted_attribute_changed? + assert record.save + end + + test "virtual attributes are not written with partial_writes off" do + with_partial_writes(ActiveRecord::Base, false) do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + attribute :non_persisted_attribute, :string + end + + record = klass.new(first_name: "Sean") + record.non_persisted_attribute_will_change! + + assert record.save + + record.non_persisted_attribute_will_change! + + assert record.save + end + end + + test "mutating and then assigning doesn't remove the change" do + pirate = Pirate.create!(catchphrase: "arrrr") + pirate.catchphrase << " matey!" + pirate.catchphrase = "arrrr matey!" + + assert pirate.catchphrase_changed?(from: "arrrr", to: "arrrr matey!") + end + + test "getters with side effects are allowed" do + klass = Class.new(Pirate) do + def catchphrase + if super.blank? + update_attribute(:catchphrase, "arr") # what could possibly go wrong? + end + super + end + end + + pirate = klass.create!(catchphrase: "lol") + pirate.update_attribute(:catchphrase, nil) + + assert_equal "arr", pirate.catchphrase + end + + test "attributes assigned but not selected are dirty" do + person = Person.select(:id).first + assert_not_predicate person, :changed? + + person.first_name = "Sean" + assert_predicate person, :changed? + + person.first_name = nil + assert_predicate person, :changed? + end + + test "attributes not selected are still missing after save" do + person = Person.select(:id).first + assert_raises(ActiveModel::MissingAttributeError) { person.first_name } + assert person.save # calls forget_attribute_assignments + assert_raises(ActiveModel::MissingAttributeError) { person.first_name } + end + + test "saved_change_to_attribute? returns whether a change occurred in the last save" do + person = Person.create!(first_name: "Sean") + + assert_predicate person, :saved_change_to_first_name? + assert_not_predicate person, :saved_change_to_gender? + assert person.saved_change_to_first_name?(from: nil, to: "Sean") + assert person.saved_change_to_first_name?(from: nil) + assert person.saved_change_to_first_name?(to: "Sean") + assert_not person.saved_change_to_first_name?(from: "Jim", to: "Sean") + assert_not person.saved_change_to_first_name?(from: "Jim") + assert_not person.saved_change_to_first_name?(to: "Jim") + end + + test "saved_change_to_attribute returns the change that occurred in the last save" do + person = Person.create!(first_name: "Sean", gender: "M") + + assert_equal [nil, "Sean"], person.saved_change_to_first_name + assert_equal [nil, "M"], person.saved_change_to_gender + + person.update(first_name: "Jim") + + assert_equal ["Sean", "Jim"], person.saved_change_to_first_name + assert_nil person.saved_change_to_gender + end + + test "attribute_before_last_save returns the original value before saving" do + person = Person.create!(first_name: "Sean", gender: "M") + + assert_nil person.first_name_before_last_save + assert_nil person.gender_before_last_save + + person.first_name = "Jim" + + assert_nil person.first_name_before_last_save + assert_nil person.gender_before_last_save + + person.save + + assert_equal "Sean", person.first_name_before_last_save + assert_equal "M", person.gender_before_last_save + end + + test "saved_changes? returns whether the last call to save changed anything" do + person = Person.create!(first_name: "Sean") + + assert_predicate person, :saved_changes? + + person.save + + assert_not_predicate person, :saved_changes? + end + + test "saved_changes returns a hash of all the changes that occurred" do + person = Person.create!(first_name: "Sean", gender: "M") + + assert_equal [nil, "Sean"], person.saved_changes[:first_name] + assert_equal [nil, "M"], person.saved_changes[:gender] + assert_equal %w(id first_name gender created_at updated_at).sort, person.saved_changes.keys.sort + + travel(1.second) do + person.update(first_name: "Jim") + end + + assert_equal ["Sean", "Jim"], person.saved_changes[:first_name] + assert_equal %w(first_name lock_version updated_at).sort, person.saved_changes.keys.sort + end + + test "changed? in after callbacks returns false" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + + after_save do + raise "changed? should be false" if changed? + raise "has_changes_to_save? should be false" if has_changes_to_save? + raise "saved_changes? should be true" unless saved_changes? + raise "id_in_database should not be nil" if id_in_database.nil? + end + end + + person = klass.create!(first_name: "Sean") + assert_not_predicate person, :changed? + end + + test "changed? in around callbacks after yield returns false" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "people" + + around_create :check_around + + def check_around + yield + raise "changed? should be false" if changed? + raise "has_changes_to_save? should be false" if has_changes_to_save? + raise "saved_changes? should be true" unless saved_changes? + raise "id_in_database should not be nil" if id_in_database.nil? + end + end + + person = klass.create!(first_name: "Sean") + assert_not_predicate person, :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_predicate pirate, :changed? + assert_predicate 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..533665d0f4 --- /dev/null +++ b/activerecord/test/cases/disconnected_test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "cases/helper" + +class TestRecord < ActiveRecord::Base +end + +class TestDisconnectedAdapter < ActiveRecord::TestCase + self.use_transactional_tests = 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 + silence_warnings do + @connection.execute "SELECT count(*) from products" + end + 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..a2efbf89f9 --- /dev/null +++ b/activerecord/test/cases/dup_test.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/reply" +require "models/topic" +require "models/movie" + +module ActiveRecord + class DupTest < ActiveRecord::TestCase + fixtures :topics + + def test_dup + assert_not_predicate Topic.new.freeze.dup, :frozen? + end + + def test_not_readonly + topic = Topic.first + + duped = topic.dup + assert_not 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_not 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_predicate 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_predicate duped, :invalid? + + topic.title = nil + duped.title = "Mathematics" + assert_predicate topic, :invalid? + assert_predicate 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_not topic.dup.approved?, "should not be overridden by default scopes" + ensure + Topic.default_scopes = prev_default_scopes + end + + def test_dup_without_primary_key + skip if current_adapter?(:OracleAdapter) + + klass = Class.new(ActiveRecord::Base) do + self.table_name = "parrots_pirates" + end + + record = klass.create! + + assert_nothing_raised do + record.dup + end + end + + def test_dup_record_not_persisted_after_rollback_transaction + movie = Movie.new(name: "test") + + assert_raises(ActiveRecord::RecordInvalid) do + Movie.transaction do + movie.save! + duped = movie.dup + duped.name = nil + duped.save! + end + end + + assert_not movie.persisted? + 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..8a0f6f6df1 --- /dev/null +++ b/activerecord/test/cases/enum_test.rb @@ -0,0 +1,563 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/book" + +class EnumTest < ActiveRecord::TestCase + fixtures :books, :authors, :author_addresses + + setup do + @book = books(:awdr) + end + + test "query state by predicate" do + assert_predicate @book, :published? + assert_not_predicate @book, :written? + assert_not_predicate @book, :proposed? + + assert_predicate @book, :read? + assert_predicate @book, :in_english? + assert_predicate @book, :author_visibility_visible? + assert_predicate @book, :illustrator_visibility_visible? + assert_predicate @book, :with_medium_font_size? + assert_predicate @book, :medium_to_read? + end + + test "query state with strings" do + assert_equal "published", @book.status + assert_equal "read", @book.read_status + assert_equal "english", @book.language + assert_equal "visible", @book.author_visibility + assert_equal "visible", @book.illustrator_visibility + assert_equal "medium", @book.difficulty + end + + test "find via scope" do + assert_equal @book, Book.published.first + assert_equal @book, Book.read.first + assert_equal @book, Book.in_english.first + assert_equal @book, Book.author_visibility_visible.first + assert_equal @book, Book.illustrator_visibility_visible.first + assert_equal @book, Book.medium_to_read.first + assert_equal books(:ddd), Book.forgotten.first + assert_equal books(:rfr), authors(:david).unpublished_books.first + end + + test "find via where with values" do + published, written = Book.statuses[:published], Book.statuses[:written] + + assert_equal @book, Book.where(status: published).first + assert_not_equal @book, Book.where(status: written).first + assert_equal @book, Book.where(status: [published]).first + assert_not_equal @book, Book.where(status: [written]).first + assert_not_equal @book, Book.where("status <> ?", published).first + assert_equal @book, Book.where("status <> ?", written).first + end + + test "find via where with symbols" do + assert_equal @book, Book.where(status: :published).first + assert_not_equal @book, Book.where(status: :written).first + assert_equal @book, Book.where(status: [:published]).first + assert_not_equal @book, Book.where(status: [:written]).first + assert_not_equal @book, Book.where.not(status: :published).first + assert_equal @book, Book.where.not(status: :written).first + assert_equal books(:ddd), Book.where(read_status: :forgotten).first + end + + test "find via where with strings" do + assert_equal @book, Book.where(status: "published").first + assert_not_equal @book, Book.where(status: "written").first + assert_equal @book, Book.where(status: ["published"]).first + assert_not_equal @book, Book.where(status: ["written"]).first + assert_not_equal @book, Book.where.not(status: "published").first + assert_equal @book, Book.where.not(status: "written").first + assert_equal books(:ddd), Book.where(read_status: "forgotten").first + end + + test "build from scope" do + assert_predicate Book.written.build, :written? + assert_not_predicate Book.written.build, :proposed? + end + + test "build from where" do + assert_predicate Book.where(status: Book.statuses[:written]).build, :written? + assert_not_predicate Book.where(status: Book.statuses[:written]).build, :proposed? + assert_predicate Book.where(status: :written).build, :written? + assert_not_predicate Book.where(status: :written).build, :proposed? + assert_predicate Book.where(status: "written").build, :written? + assert_not_predicate Book.where(status: "written").build, :proposed? + end + + test "update by declaration" do + @book.written! + assert_predicate @book, :written? + @book.in_english! + assert_predicate @book, :in_english? + @book.author_visibility_visible! + assert_predicate @book, :author_visibility_visible? + end + + test "update by setter" do + @book.update! status: :written + assert_predicate @book, :written? + end + + test "enum methods are overwritable" do + assert_equal "do publish work...", @book.published! + assert_predicate @book, :published? + end + + test "direct assignment" do + @book.status = :written + assert_predicate @book, :written? + end + + test "assign string value" do + @book.status = "written" + assert_predicate @book, :written? + end + + test "enum changed attributes" do + old_status = @book.status + old_language = @book.language + @book.status = :proposed + @book.language = :spanish + assert_equal old_status, @book.changed_attributes[:status] + assert_equal old_language, @book.changed_attributes[:language] + end + + test "enum changes" do + old_status = @book.status + old_language = @book.language + @book.status = :proposed + @book.language = :spanish + assert_equal [old_status, "proposed"], @book.changes[:status] + assert_equal [old_language, "spanish"], @book.changes[:language] + end + + test "enum attribute was" do + old_status = @book.status + old_language = @book.language + @book.status = :published + @book.language = :spanish + assert_equal old_status, @book.attribute_was(:status) + assert_equal old_language, @book.attribute_was(:language) + end + + test "enum attribute changed" do + @book.status = :proposed + @book.language = :french + assert @book.attribute_changed?(:status) + assert @book.attribute_changed?(:language) + end + + test "enum attribute changed to" do + @book.status = :proposed + @book.language = :french + assert @book.attribute_changed?(:status, to: "proposed") + assert @book.attribute_changed?(:language, to: "french") + end + + test "enum attribute changed from" do + old_status = @book.status + old_language = @book.language + @book.status = :proposed + @book.language = :french + assert @book.attribute_changed?(:status, from: old_status) + assert @book.attribute_changed?(:language, from: old_language) + end + + test "enum attribute changed from old status to new status" do + old_status = @book.status + old_language = @book.language + @book.status = :proposed + @book.language = :french + assert @book.attribute_changed?(:status, from: old_status, to: "proposed") + assert @book.attribute_changed?(:language, from: old_language, to: "french") + 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 = :proposed + 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 = :proposed + 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 "NULL values from database should be casted to nil" do + Book.where(id: @book.id).update_all("status = NULL") + assert_nil @book.reload.status + end + + test "assign nil value" do + @book.status = nil + assert_nil @book.status + end + + test "assign empty string value" do + @book.status = "" + assert_nil @book.status + end + + test "assign long empty string value" do + @book.status = " " + assert_nil @book.status + 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_predicate Book.written.build, :written? + assert_predicate Book.read.build, :read? + assert_predicate Book.in_spanish.build, :in_spanish? + assert_predicate Book.illustrator_visibility_invisible.build, :illustrator_visibility_invisible? + end + + test "creating new objects with enum scopes" do + assert_predicate Book.written.create, :written? + assert_predicate Book.read.create, :read? + assert_predicate Book.in_spanish.create, :in_spanish? + assert_predicate Book.illustrator_visibility_invisible.create, :illustrator_visibility_invisible? + end + + test "_before_type_cast" do + assert_equal 2, @book.status_before_type_cast + assert_equal "published", @book.status + + @book.status = "published" + + assert_equal "published", @book.status_before_type_cast + assert_equal "published", @book.status + end + + test "invalid definition values raise an ArgumentError" do + e = assert_raises(ArgumentError) do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [proposed: 1, written: 2, published: 3] + end + end + + assert_match(/must be either a hash, an array of symbols, or an array of strings./, e.message) + + e = assert_raises(ArgumentError) do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: { "" => 1, "active" => 2 } + end + end + + assert_match(/Enum label name must not be blank/, e.message) + + e = assert_raises(ArgumentError) do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: ["active", ""] + end + end + + assert_match(/Enum label name must not be blank/, e.message) + 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| + e = assert_raises(ArgumentError) do + klass.class_eval { enum name => ["value_#{i}"] } + end + assert_match(/You tried to define an enum named \"#{name}\" on the model/, e.message) + 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, # some important methods on Module and Class + :name, :parent, :superclass + ] + + conflicts.each_with_index do |value, i| + e = assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do + klass.class_eval { enum "status_#{i}" => [value] } + end + assert_match(/You tried to define an enum named .* on the model/, e.message) + end + end + + test "reserved enum values for relation" do + relation_method_samples = [ + :records, + :to_ary, + :scope_for_create + ] + + relation_method_samples.each do |value| + e = assert_raises(ArgumentError, "enum value `#{value}` should not be allowed") do + Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum category: [:other, value] + end + end + assert_match(/You tried to define an enum named .* on the model/, e.message) + 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_predicate book, :valid? + book.status = "proposed" + assert_not_predicate 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_predicate invalid_book, :valid? + valid_book = klass.new(status: "written") + assert_predicate 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 + + test "attempting to modify enum raises error" do + e = assert_raises(RuntimeError) do + Book.statuses["bad_enum"] = 40 + end + + assert_match(/can't modify frozen/, e.message) + + e = assert_raises(RuntimeError) do + Book.statuses.delete("published") + end + + assert_match(/can't modify frozen/, e.message) + end + + test "declare multiple enums at a time" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:proposed, :written, :published], + nullable_status: [:single, :married] + end + + book1 = klass.proposed.create! + assert_predicate book1, :proposed? + + book2 = klass.single.create! + assert_predicate book2, :single? + end + + test "enum with alias_attribute" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "books" + alias_attribute :aliased_status, :status + enum aliased_status: [:proposed, :written, :published] + end + + book = klass.proposed.create! + assert_predicate book, :proposed? + assert_equal "proposed", book.aliased_status + + book = klass.find(book.id) + assert_predicate book, :proposed? + assert_equal "proposed", book.aliased_status + end + + test "query state by predicate with prefix" do + assert_predicate @book, :author_visibility_visible? + assert_not_predicate @book, :author_visibility_invisible? + assert_predicate @book, :illustrator_visibility_visible? + assert_not_predicate @book, :illustrator_visibility_invisible? + end + + test "query state by predicate with custom prefix" do + assert_predicate @book, :in_english? + assert_not_predicate @book, :in_spanish? + assert_not_predicate @book, :in_french? + end + + test "query state by predicate with custom suffix" do + assert_predicate @book, :medium_to_read? + assert_not_predicate @book, :easy_to_read? + assert_not_predicate @book, :hard_to_read? + end + + test "enum methods with custom suffix defined" do + assert_respond_to @book.class, :easy_to_read + assert_respond_to @book.class, :medium_to_read + assert_respond_to @book.class, :hard_to_read + + assert_respond_to @book, :easy_to_read? + assert_respond_to @book, :medium_to_read? + assert_respond_to @book, :hard_to_read? + + assert_respond_to @book, :easy_to_read! + assert_respond_to @book, :medium_to_read! + assert_respond_to @book, :hard_to_read! + end + + test "update enum attributes with custom suffix" do + @book.medium_to_read! + assert_not_predicate @book, :easy_to_read? + assert_predicate @book, :medium_to_read? + assert_not_predicate @book, :hard_to_read? + + @book.easy_to_read! + assert_predicate @book, :easy_to_read? + assert_not_predicate @book, :medium_to_read? + assert_not_predicate @book, :hard_to_read? + + @book.hard_to_read! + assert_not_predicate @book, :easy_to_read? + assert_not_predicate @book, :medium_to_read? + assert_predicate @book, :hard_to_read? + end + + test "uses default status when no status is provided in fixtures" do + book = books(:tlg) + assert book.proposed?, "expected fixture to default to proposed status" + assert book.in_english?, "expected fixture to default to english language" + end + + test "uses default value from database on initialization" do + book = Book.new + assert_predicate book, :proposed? + end + + test "uses default value from database on initialization when using custom mapping" do + book = Book.new + assert_predicate book, :hard? + end + + test "data type of Enum type" do + assert_equal :integer, Book.type_for_attribute("status").type + end + + test "scopes can be disabled" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "books" + enum status: [:proposed, :written], _scopes: false + end + + assert_raises(NoMethodError) { klass.proposed } + end +end diff --git a/activerecord/test/cases/errors_test.rb b/activerecord/test/cases/errors_test.rb new file mode 100644 index 0000000000..0d2be944b5 --- /dev/null +++ b/activerecord/test/cases/errors_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ErrorsTest < ActiveRecord::TestCase + def test_can_be_instantiated_with_no_args + base = ActiveRecord::ActiveRecordError + error_klasses = ObjectSpace.each_object(Class).select { |klass| klass < base } + + (error_klasses - [ActiveRecord::AmbiguousSourceReflectionForThroughAssociation]).each do |error_klass| + error_klass.new.inspect + rescue ArgumentError + raise "Instance of #{error_klass} can't be initialized with no arguments" + end + 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..79a0630193 --- /dev/null +++ b/activerecord/test/cases/explain_subscriber_test.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +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_empty queries + end + + def test_collects_nothing_for_ignored_payloads + ActiveRecord::ExplainSubscriber::IGNORED_PAYLOADS.each do |ip| + SUBSCRIBER.finish(nil, nil, name: ip) + end + assert_empty queries + 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_empty queries + 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_explainable + SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "SHOW max_identifier_length") + assert_empty queries + end + + def test_collects_nothing_if_the_statement_is_only_partially_matched + SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "select_db yo_mama") + assert_empty queries + end + + def test_collects_cte_queries + SUBSCRIBER.finish(nil, nil, name: "SQL", sql: "with s as (values(3)) select 1 from s") + assert_equal 1, queries.size + 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..a0e75f4e89 --- /dev/null +++ b/activerecord/test/cases/explain_test.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/car" + +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.last.value + else + assert_match "honda", sql + end + end + + def test_exec_explain_with_no_binds + sqls = %w(foo bar) + binds = [[], []] + queries = sqls.zip(binds) + + stub_explain_for_query_plans do + expected = sqls.map { |sql| "EXPLAIN for: #{sql}\nquery plan #{sql}" }.join("\n") + assert_equal expected, base.exec_explain(queries) + end + end + + def test_exec_explain_with_binds + sqls = %w(foo bar) + binds = [[bind_param("wadus", 1)], [bind_param("chaflan", 2)]] + queries = sqls.zip(binds) + + stub_explain_for_query_plans(["query plan foo\n", "query plan bar\n"]) do + expected = <<~SQL + 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 + end + + def test_unsupported_connection_adapter + connection.stub(:supports_explain?, false) do + assert_not_called(base.logger, :warn) do + Car.where(name: "honda").to_a + end + end + end + + private + + def stub_explain_for_query_plans(query_plans = ["query plan foo", "query plan bar"]) + explain_called = 0 + + connection.stub(:explain, proc { explain_called += 1; query_plans[explain_called - 1] }) do + yield + end + end + + def bind_param(name, value) + ActiveRecord::Relation::QueryAttribute.new(name, value, ActiveRecord::Type::Value.new) + end + end +end diff --git a/activerecord/test/cases/filter_attributes_test.rb b/activerecord/test/cases/filter_attributes_test.rb new file mode 100644 index 0000000000..2f4c9b0ef7 --- /dev/null +++ b/activerecord/test/cases/filter_attributes_test.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/admin" +require "models/admin/user" +require "models/admin/account" +require "models/user" +require "pp" + +class FilterAttributesTest < ActiveRecord::TestCase + fixtures :"admin/users", :"admin/accounts" + + setup do + @previous_filter_attributes = ActiveRecord::Base.filter_attributes + ActiveRecord::Base.filter_attributes = [:name] + end + + teardown do + ActiveRecord::Base.filter_attributes = @previous_filter_attributes + end + + test "filter_attributes" do + Admin::User.all.each do |user| + assert_includes user.inspect, "name: [FILTERED]" + assert_equal 1, user.inspect.scan("[FILTERED]").length + end + + Admin::Account.all.each do |account| + assert_includes account.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + end + + test "string filter_attributes perform pertial match" do + ActiveRecord::Base.filter_attributes = ["n"] + Admin::Account.all.each do |account| + assert_includes account.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + end + + test "regex filter_attributes are accepted" do + ActiveRecord::Base.filter_attributes = [/\An\z/] + account = Admin::Account.find_by(name: "37signals") + assert_includes account.inspect, 'name: "37signals"' + assert_equal 0, account.inspect.scan("[FILTERED]").length + + ActiveRecord::Base.filter_attributes = [/\An/] + account = Admin::Account.find_by(name: "37signals") + assert_includes account.reload.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + + test "proc filter_attributes are accepted" do + ActiveRecord::Base.filter_attributes = [ lambda { |key, value| value.reverse! if key == "name" } ] + account = Admin::Account.find_by(name: "37signals") + assert_includes account.inspect, 'name: "slangis73"' + end + + test "filter_attributes could be overwritten by models" do + Admin::Account.all.each do |account| + assert_includes account.inspect, "name: [FILTERED]" + assert_equal 1, account.inspect.scan("[FILTERED]").length + end + + begin + Admin::Account.filter_attributes = [] + + # Above changes should not impact other models + Admin::User.all.each do |user| + assert_includes user.inspect, "name: [FILTERED]" + assert_equal 1, user.inspect.scan("[FILTERED]").length + end + + Admin::Account.all.each do |account| + assert_not_includes account.inspect, "name: [FILTERED]" + assert_equal 0, account.inspect.scan("[FILTERED]").length + end + ensure + Admin::Account.remove_instance_variable(:@filter_attributes) + end + end + + test "filter_attributes should not filter nil value" do + account = Admin::Account.new + + assert_includes account.inspect, "name: nil" + assert_not_includes account.inspect, "name: [FILTERED]" + assert_equal 0, account.inspect.scan("[FILTERED]").length + end + + test "filter_attributes should handle [FILTERED] value properly" do + User.filter_attributes = ["auth"] + user = User.new(token: "[FILTERED]", auth_token: "[FILTERED]") + + assert_includes user.inspect, "auth_token: [FILTERED]" + assert_includes user.inspect, 'token: "[FILTERED]"' + ensure + User.remove_instance_variable(:@filter_attributes) + end + + test "filter_attributes on pretty_print" do + user = admin_users(:david) + actual = "".dup + PP.pp(user, StringIO.new(actual)) + + assert_includes actual, "name: [FILTERED]" + assert_equal 1, actual.scan("[FILTERED]").length + end + + test "filter_attributes on pretty_print should not filter nil value" do + user = Admin::User.new + actual = "".dup + PP.pp(user, StringIO.new(actual)) + + assert_includes actual, "name: nil" + assert_not_includes actual, "name: [FILTERED]" + assert_equal 0, actual.scan("[FILTERED]").length + end + + test "filter_attributes on pretty_print should handle [FILTERED] value properly" do + User.filter_attributes = ["auth"] + user = User.new(token: "[FILTERED]", auth_token: "[FILTERED]") + actual = "".dup + PP.pp(user, StringIO.new(actual)) + + assert_includes actual, "auth_token: [FILTERED]" + assert_includes actual, 'token: "[FILTERED]"' + ensure + User.remove_instance_variable(:@filter_attributes) + 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..66413a98e4 --- /dev/null +++ b/activerecord/test/cases/finder_respond_to_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +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_not_respond_to ActiveRecord::Base, :find_by_something + end + + def test_should_preserve_normal_respond_to_behaviour_and_respond_to_newly_added_method + Topic.singleton_class.define_method(:method_added_for_finder_respond_to_test) { } + assert_respond_to Topic, :method_added_for_finder_respond_to_test + ensure + Topic.singleton_class.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_not_respond_to Topic, :find_by_undertitle + end + + def test_should_not_respond_to_find_by_invalid_method_syntax + assert_not_respond_to Topic, :fail_to_find_by_title + assert_not_respond_to Topic, :find_by_title? + assert_not_respond_to Topic, :fail_to_find_or_create_by_title + assert_not_respond_to Topic, :find_or_create_by_title? + end + + private + + def ensure_topic_method_is_not_cached(method_id) + Topic.singleton_class.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..961ae03a4c --- /dev/null +++ b/activerecord/test/cases/finder_test.rb @@ -0,0 +1,1422 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/author" +require "models/categorization" +require "models/comment" +require "models/company" +require "models/tagging" +require "models/topic" +require "models/reply" +require "models/rating" +require "models/entrant" +require "models/project" +require "models/developer" +require "models/computer" +require "models/customer" +require "models/toy" +require "models/matey" +require "models/dog" +require "models/car" +require "models/tyre" +require "models/subscriber" + +class FinderTest < ActiveRecord::TestCase + fixtures :companies, :topics, :entrants, :developers, :developers_projects, :posts, :comments, :accounts, :authors, :author_addresses, :customers, :categories, :categorizations, :cars + + def test_find_by_id_with_hash + assert_nothing_raised do + Post.find_by_id(limit: 1) + end + end + + def test_find_by_title_and_id_with_hash + assert_nothing_raised 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 do + Topic.all.find(-> { raise "should not happen" }) { |e| e.title == topics(:first).title } + end + end + + def test_find_with_ids_returning_ordered + records = Topic.find([4, 2, 5]) + assert_equal "The Fourth Topic of the day", records[0].title + assert_equal "The Second Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + + records = Topic.find(4, 2, 5) + assert_equal "The Fourth Topic of the day", records[0].title + assert_equal "The Second Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + + records = Topic.find(["4", "2", "5"]) + assert_equal "The Fourth Topic of the day", records[0].title + assert_equal "The Second Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + + records = Topic.find("4", "2", "5") + assert_equal "The Fourth Topic of the day", records[0].title + assert_equal "The Second Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + end + + def test_find_with_ids_and_order_clause + # The order clause takes precedence over the informed ids + records = Topic.order(:author_name).find([5, 3, 1]) + assert_equal "The Third Topic of the day", records[0].title + assert_equal "The First Topic", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + + records = Topic.order(:id).find([5, 3, 1]) + assert_equal "The First Topic", records[0].title + assert_equal "The Third Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + end + + def test_find_with_ids_with_limit_and_order_clause + # The order clause takes precedence over the informed ids + records = Topic.limit(2).order(:id).find([5, 3, 1]) + assert_equal 2, records.size + assert_equal "The First Topic", records[0].title + assert_equal "The Third Topic of the day", records[1].title + end + + def test_find_with_ids_and_limit + records = Topic.limit(3).find([3, 2, 5, 1, 4]) + assert_equal 3, records.size + assert_equal "The Third Topic of the day", records[0].title + assert_equal "The Second Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + end + + def test_find_with_ids_where_and_limit + # Please note that Topic 1 is the only not approved so + # if it were among the first 3 it would raise an ActiveRecord::RecordNotFound + records = Topic.where(approved: true).limit(3).find([3, 2, 5, 1, 4]) + assert_equal 3, records.size + assert_equal "The Third Topic of the day", records[0].title + assert_equal "The Second Topic of the day", records[1].title + assert_equal "The Fifth Topic of the day", records[2].title + end + + def test_find_with_ids_and_offset + records = Topic.offset(2).find([3, 2, 5, 1, 4]) + assert_equal 3, records.size + assert_equal "The Fifth Topic of the day", records[0].title + assert_equal "The First Topic", records[1].title + assert_equal "The Fourth Topic of the day", records[2].title + end + + def test_find_with_ids_with_no_id_passed + exception = assert_raises(ActiveRecord::RecordNotFound) { Topic.find } + assert_equal exception.model, "Topic" + assert_equal exception.primary_key, "id" + end + + def test_find_with_ids_with_id_out_of_range + exception = assert_raises(ActiveRecord::RecordNotFound) do + Topic.find("9999999999999999999999999999999") + end + + assert_equal exception.model, "Topic" + assert_equal exception.primary_key, "id" + end + + def test_find_passing_active_record_object_is_not_permitted + assert_raises(ArgumentError) do + Topic.find(Topic.last) + end + end + + def test_symbols_table_ref + gc_disabled = GC.disable + Post.where("author_id" => nil) # warm up + x = Symbol.all_symbols.count + Post.where("title" => { "xxxqqqq" => "bar" }) + assert_equal x, Symbol.all_symbols.count + ensure + GC.enable if gc_disabled == false + 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?(9999999999999999999999999999999) + assert_equal false, Topic.exists?(Topic.new.id) + + assert_raise(NoMethodError) { Topic.exists?([1, 2]) } + end + + def test_exists_with_scope + davids = Author.where(name: "David") + assert_equal true, davids.exists? + assert_equal true, davids.exists?(authors(:david).id) + assert_equal false, davids.exists?(authors(:mary).id) + assert_equal false, davids.exists?("42") + assert_equal false, davids.exists?(42) + assert_equal false, davids.exists?(davids.new.id) + + fake = Author.where(name: "fake author") + assert_equal false, fake.exists? + assert_equal false, fake.exists?(authors(:david).id) + end + + def test_exists_uses_existing_scope + post = authors(:david).posts.first + authors = Author.includes(:posts).where(name: "David", posts: { id: post.id }) + assert_equal true, authors.exists?(authors(:david).id) + end + + def test_any_with_scope_on_hash_includes + post = authors(:david).posts.first + categories = Categorization.includes(author: :posts).where(posts: { id: post.id }) + assert_equal true, categories.exists? + end + + def test_exists_with_polymorphic_relation + post = Post.create!(title: "Post", body: "default", taggings: [Tagging.new(comment: "tagging comment")]) + relation = Post.tagged_with_comment("tagging comment") + + assert_equal true, relation.exists?(title: ["Post"]) + assert_equal true, relation.exists?(["title LIKE ?", "Post%"]) + assert_equal true, relation.exists? + assert_equal true, relation.exists?(post.id) + assert_equal true, relation.exists?(post.id.to_s) + + assert_equal false, relation.exists?(false) + end + + def test_exists_with_string + assert_equal false, Subscriber.exists?("foo") + assert_equal false, Subscriber.exists?(" ") + + Subscriber.create!(id: "foo") + Subscriber.create!(id: " ") + + assert_equal true, Subscriber.exists?("foo") + assert_equal true, Subscriber.exists?(" ") + end + + def test_exists_passing_active_record_object_is_not_permitted + assert_raises(ArgumentError) do + Topic.exists?(Topic.new) + 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 + + def test_exists_with_empty_hash_arg + assert_equal true, Topic.exists?({}) + end + + # Ensure +exists?+ runs without an error by excluding distinct value. + # See https://github.com/rails/rails/pull/26981. + def test_exists_with_order_and_distinct + assert_equal true, Topic.order(:id).distinct.exists? + end + + # Ensure +exists?+ runs without an error by excluding order value. + def test_exists_with_order + assert_equal true, Topic.order(Arel.sql("invalid sql here")).exists? + end + + def test_exists_with_joins + assert_equal true, Topic.joins(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists? + end + + def test_exists_with_left_joins + assert_equal true, Topic.left_joins(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists? + end + + def test_exists_with_eager_load + assert_equal true, Topic.eager_load(:replies).where(replies_topics: { approved: true }).order("replies_topics.created_at DESC").exists? + end + + def test_exists_with_includes_limit_and_empty_result + assert_no_queries { assert_equal false, Topic.includes(:replies).limit(0).exists? } + assert_queries(1) { 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 + unique_categorized_posts = author.unique_categorized_posts.includes(:special_comments) + assert_no_queries { assert_equal false, unique_categorized_posts.limit(0).exists? } + assert_queries(1) { assert_equal true, unique_categorized_posts.limit(1).exists? } + end + + def test_exists_with_distinct_association_includes_limit_and_order + author = Author.first + unique_categorized_posts = author.unique_categorized_posts.includes(:special_comments).order("comments.tags_count DESC") + assert_no_queries { assert_equal false, unique_categorized_posts.limit(0).exists? } + assert_queries(1) { assert_equal true, unique_categorized_posts.limit(1).exists? } + end + + def test_exists_should_reference_correct_aliases_while_joining_tables_of_has_many_through_association + ratings = developers(:david).ratings.includes(comment: :post).where(posts: { id: 1 }) + assert_queries(1) { assert_not_predicate ratings.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 + assert_not_called(Developer, :instantiate) do + Developer.exists? + end + 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 + entrants = Entrant.limit(3).offset(2).find([1, 3, 2]) + assert_equal 1, entrants.size + assert_equal "Ruby Guru", entrants.first.name + + # 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 + assert_equal "fixture_10", last_devs[0].name + assert_equal "Jamis", last_devs[1].name + end + + def test_find_with_large_number + assert_raises(ActiveRecord::RecordNotFound) { Topic.find("9999999999999999999999999999999") } + end + + def test_find_by_with_large_number + assert_nil Topic.find_by(id: "9999999999999999999999999999999") + end + + def test_find_by_id_with_large_number + assert_nil Topic.find_by_id("9999999999999999999999999999999") + end + + def test_find_on_relation_with_large_number + assert_raises(ActiveRecord::RecordNotFound) do + Topic.where("1=1").find(9999999999999999999999999999999) + end + end + + def test_find_by_on_relation_with_large_number + assert_nil Topic.where("1=1").find_by(id: 9999999999999999999999999999999) + end + + def test_find_by_bang_on_relation_with_large_number + assert_raises(ActiveRecord::RecordNotFound) do + Topic.where("1=1").find_by!(id: 9999999999999999999999999999999) + end + end + + def test_find_an_empty_array + empty_array = [] + result = Topic.find(empty_array) + assert_equal [], result + assert_not_same empty_array, result + 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_find_by_association_subquery + author = authors(:david) + assert_equal author.post, Post.find_by(author: Author.where(id: author)) + assert_equal author.post, Post.find_by(author_id: Author.where(id: author)) + end + + def test_find_by_and_where_consistency_with_active_record_instance + author = authors(:david) + assert_equal Post.where(author_id: author).take, Post.find_by(author_id: author) + end + + def test_take + assert_equal topics(:first), Topic.where("title = 'The 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_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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 + assert_equal expected, Topic.limit(5).first + end + + def test_model_class_responds_to_first_bang + assert Topic.first! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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 + assert_equal expected, Topic.limit(5).second + end + + def test_model_class_responds_to_second_bang + assert Topic.second! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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 + assert_equal expected, Topic.limit(5).third + end + + def test_model_class_responds_to_third_bang + assert Topic.third! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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 + assert_equal expected, Topic.limit(5).fourth + end + + def test_model_class_responds_to_fourth_bang + assert Topic.fourth! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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 + assert_equal expected, Topic.limit(5).fifth + end + + def test_model_class_responds_to_fifth_bang + assert Topic.fifth! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do + Topic.fifth! + end + end + + def test_second_to_last + assert_equal topics(:fourth).title, Topic.second_to_last.title + + # test with offset + assert_equal topics(:fourth), Topic.offset(1).second_to_last + assert_equal topics(:fourth), Topic.offset(2).second_to_last + assert_equal topics(:fourth), Topic.offset(3).second_to_last + assert_nil Topic.offset(4).second_to_last + assert_nil Topic.offset(5).second_to_last + + # test with limit + assert_nil Topic.limit(1).second + assert_nil Topic.limit(1).second_to_last + end + + def test_second_to_last_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.second_to_last + end + + def test_model_class_responds_to_second_to_last_bang + assert Topic.second_to_last! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do + Topic.second_to_last! + end + end + + def test_third_to_last + assert_equal topics(:third).title, Topic.third_to_last.title + + # test with offset + assert_equal topics(:third), Topic.offset(1).third_to_last + assert_equal topics(:third), Topic.offset(2).third_to_last + assert_nil Topic.offset(3).third_to_last + assert_nil Topic.offset(4).third_to_last + assert_nil Topic.offset(5).third_to_last + + # test with limit + assert_nil Topic.limit(1).third + assert_nil Topic.limit(1).third_to_last + assert_nil Topic.limit(2).third + assert_nil Topic.limit(2).third_to_last + end + + def test_third_to_last_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_to_last + end + + def test_model_class_responds_to_third_to_last_bang + assert Topic.third_to_last! + Topic.delete_all + assert_raises_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do + Topic.third_to_last! + 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_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" 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_with_message ActiveRecord::RecordNotFound, "Couldn't find Topic" do + Topic.delete_all + Topic.last! + end + end + + def test_take_and_first_and_last_with_integer_should_use_sql_limit + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) { Topic.take(3).entries } + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) { Topic.first(2).entries } + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) { 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_use_sql_limit + relation = Topic.order("title") + assert_queries(1) { relation.last(5) } + assert_not_predicate relation, :loaded? + end + + def test_last_with_integer_and_reorder_should_use_sql_limit + relation = Topic.reorder("title") + assert_queries(1) { relation.last(5) } + assert_not_predicate relation, :loaded? + end + + def test_last_on_loaded_relation_should_not_use_sql + relation = Topic.limit(10).load + assert_no_queries do + relation.last + relation.last(2) + end + end + + def test_last_with_irreversible_order + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("coalesce(author_name, title)")).last + end + end + + def test_last_on_relation_with_limit_and_offset + post = posts("sti_comments") + + comments = post.comments.order(id: :asc) + assert_equal comments.limit(2).to_a.last, comments.limit(2).last + assert_equal comments.limit(2).to_a.last(2), comments.limit(2).last(2) + assert_equal comments.limit(2).to_a.last(3), comments.limit(2).last(3) + + assert_equal comments.offset(2).to_a.last, comments.offset(2).last + assert_equal comments.offset(2).to_a.last(2), comments.offset(2).last(2) + assert_equal comments.offset(2).to_a.last(3), comments.offset(2).last(3) + + comments = comments.offset(1) + assert_equal comments.limit(2).to_a.last, comments.limit(2).last + assert_equal comments.limit(2).to_a.last(2), comments.limit(2).last(2) + assert_equal comments.limit(2).to_a.last(3), comments.limit(2).last(3) + end + + def test_first_on_relation_with_limit_and_offset + post = posts("sti_comments") + + comments = post.comments.order(id: :asc) + assert_equal comments.limit(2).to_a.first, comments.limit(2).first + assert_equal comments.limit(2).to_a.first(2), comments.limit(2).first(2) + assert_equal comments.limit(2).to_a.first(3), comments.limit(2).first(3) + + assert_equal comments.offset(2).to_a.first, comments.offset(2).first + assert_equal comments.offset(2).to_a.first(2), comments.offset(2).first(2) + assert_equal comments.offset(2).to_a.first(3), comments.offset(2).first(3) + + comments = comments.offset(1) + assert_equal comments.limit(2).to_a.first, comments.limit(2).first + assert_equal comments.limit(2).to_a.first(2), comments.limit(2).first(2) + assert_equal comments.limit(2).to_a.first(3), comments.limit(2).first(3) + end + + def test_first_have_determined_order_by_default + expected = [companies(:second_client), companies(:another_client)] + clients = Client.where(name: expected.map(&:name)) + + assert_equal expected, clients.first(2) + assert_equal expected, clients.limit(5).first(2) + end + + def test_implicit_order_column_is_configurable + old_implicit_order_column = Topic.implicit_order_column + Topic.implicit_order_column = "title" + + assert_equal topics(:fifth), Topic.first + assert_equal topics(:third), Topic.last + ensure + Topic.implicit_order_column = old_implicit_order_column + 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_not topic.attribute_present?("title") + assert_not 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_qualified_attribute_dot_notation_string + 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_qualified_attribute_dot_notation_symbol + 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_on_combined_explicit_and_hashed_table_names + assert Topic.where("topics.approved" => false, topics: { author_name: "David" }).find(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.where("topics.approved" => true, topics: { author_name: "David" }).find(1) } + assert_raise(ActiveRecord::RecordNotFound) { Topic.where("topics.approved" => false, topics: { author_name: "Melanie" }).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_hash_conditions_with_array_of_ranges + assert_equal [1, 2, 6, 7, 8], Comment.where(id: [1..2, 6..8]).to_a.map(&:id).sort + end + + def test_find_on_hash_conditions_with_open_ended_range + assert_equal [1, 2, 3], Comment.where(id: Float::INFINITY..3).to_a.map(&:id).sort + end + + def test_find_on_hash_conditions_with_numeric_range_for_string + topic = Topic.create!(title: "12 Factor App") + assert_equal [topic], Topic.where(title: 10..2).to_a + 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) } + 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_having_three_mappings_array + david_address = customers(:david).address + zaphod_address = customers(:zaphod).address + barney_address = customers(:barney).address + assert_kind_of Address, david_address + assert_kind_of Address, zaphod_address + found_customers = Customer.where(address: [david_address, zaphod_address, barney_address]) + assert_equal [customers(:david), customers(:zaphod), customers(:barney)], found_customers.sort_by(&:id) + end + + def test_hash_condition_find_with_aggregate_having_one_mapping_array + david_balance = customers(:david).balance + zaphod_balance = customers(:zaphod).balance + assert_kind_of Money, david_balance + assert_kind_of Money, zaphod_balance + found_customers = Customer.where(balance: [david_balance, zaphod_balance]) + assert_equal [customers(:david), customers(:zaphod)], found_customers.sort_by(&:id) + 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 against") + assert Company.where(["name = ?", "37signals' go'es against"]).first + end + + def test_named_bind_variables_with_quotes + Company.create("name" => "37signals' go'es against") + assert Company.where(["name = :name", { name: "37signals' go'es against" }]).first + end + + def test_named_bind_variables + 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 + + 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_raises_with_message(ActiveRecord::RecordNotFound, "Couldn't find Topic") do + Topic.find_by_title!("The First Topic!") + end + 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 + Account.singleton_class.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_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_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 + + 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!(&: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). + where.not(author_addresses: { id: nil }). + order("author_addresses.id DESC").limit(2).to_a.size + + assert_equal 3, Post.includes(author: :author_address, authors: :author_address). + where.not(author_addresses_authors: { id: nil }). + order("author_addresses_authors.id DESC").limit(3).to_a.size + end + + def test_find_with_eager_loading_collection_and_ordering_by_collection_primary_key + assert_equal Post.first, Post.eager_load(comments: :ratings). + order("posts.id, ratings.id, comments.id").first + 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(&:client_of) + + assert_includes client_of, 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(&: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_on_primary_key + e = assert_raises(ActiveRecord::RecordNotFound) do + Car.find(0) + end + assert_equal 0, e.id + assert_equal "id", e.primary_key + assert_equal "Car", e.model + assert_equal "Couldn't find Car with 'id'=0", e.message + 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 "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 "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 { Topic.offset("3").to_a } + end + + test "find_by with hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by(id: posts(:eager_other).id) + end + + test "find_by with non-hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by("id = #{posts(:eager_other).id}") + end + + test "find_by with multi-arg conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by("id = ?", posts(:eager_other).id) + end + + test "find_by with range conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by(id: posts(:eager_other).id...posts(:misc_by_bob).id) + end + + test "find_by returns nil if the record is missing" do + assert_nil Post.find_by("1 = 0") + end + + test "find_by with associations" do + assert_equal authors(:david), Post.find_by(author: authors(:david)).author + assert_equal authors(:mary), Post.find_by(author: authors(:mary)).author + end + + test "find_by doesn't have implicit ordering" do + assert_sql(/^((?!ORDER).)*$/) { Post.find_by(id: posts(:eager_other).id) } + end + + test "find_by! with hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by!(id: posts(:eager_other).id) + end + + test "find_by! with non-hash conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by!("id = #{posts(:eager_other).id}") + end + + test "find_by! with multi-arg conditions returns the first matching record" do + assert_equal posts(:eager_other), Post.find_by!("id = ?", posts(:eager_other).id) + end + + test "find_by! doesn't have implicit ordering" do + assert_sql(/^((?!ORDER).)*$/) { Post.find_by!(id: posts(:eager_other).id) } + end + + test "find_by! raises RecordNotFound if the record is missing" do + assert_raises(ActiveRecord::RecordNotFound) do + Post.find_by!("1 = 0") + end + end + + test "find on a scope does not perform statement caching" do + honda = cars(:honda) + zyke = cars(:zyke) + tyre = honda.tyres.create! + tyre2 = zyke.tyres.create! + + assert_equal tyre, honda.tyres.custom_find(tyre.id) + assert_equal tyre2, zyke.tyres.custom_find(tyre2.id) + end + + test "find_by on a scope does not perform statement caching" do + honda = cars(:honda) + zyke = cars(:zyke) + tyre = honda.tyres.create! + tyre2 = zyke.tyres.create! + + assert_equal tyre, honda.tyres.custom_find_by(id: tyre.id) + assert_equal tyre2, zyke.tyres.custom_find_by(id: tyre2.id) + end + + test "#skip_query_cache! for #exists?" do + Topic.cache do + assert_queries(1) do + Topic.exists? + Topic.exists? + end + + assert_queries(2) do + Topic.all.skip_query_cache!.exists? + Topic.all.skip_query_cache!.exists? + end + end + end + + test "#skip_query_cache! for #exists? with a limited eager load" do + Topic.cache do + assert_queries(1) do + Topic.eager_load(:replies).limit(1).exists? + Topic.eager_load(:replies).limit(1).exists? + end + + assert_queries(2) do + Topic.eager_load(:replies).limit(1).skip_query_cache!.exists? + Topic.eager_load(:replies).limit(1).skip_query_cache!.exists? + end + end + end + + private + def table_with_custom_primary_key + yield(Class.new(Toy) do + def self.name + "MercedesCar" + end + end) + end + + def assert_raises_with_message(exception_class, message, &block) + err = assert_raises(exception_class) { block.call } + assert_match message, err.message + 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..ff99988cb5 --- /dev/null +++ b/activerecord/test/cases/fixture_set/file_test.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +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 + + def test_removes_fixture_config_row + File.open(::File.join(FIXTURES_ROOT, "other_posts.yml")) do |fh| + assert_equal(["second_welcome"], fh.each.map { |name, _| name }) + end + end + + def test_extracts_model_class_from_config_row + File.open(::File.join(FIXTURES_ROOT, "other_posts.yml")) do |fh| + assert_equal "Post", fh.model_class + end + end + + def test_erb_filename + filename = "filename.yaml" + erb = File.new(filename).send(:prepare_erb, "<% Rails.env %>\n") + assert_equal erb.filename, filename + 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..fe2f417a04 --- /dev/null +++ b/activerecord/test/cases/fixtures_test.rb @@ -0,0 +1,1364 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_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/bulb" +require "models/category" +require "models/post" +require "models/comment" +require "models/company" +require "models/computer" +require "models/course" +require "models/developer" +require "models/dog" +require "models/doubloon" +require "models/joke" +require "models/matey" +require "models/other_dog" +require "models/parrot" +require "models/pirate" +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 + include ConnectionHelper + + self.use_instantiated_fixtures = true + self.use_transactional_tests = 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 + + class InsertQuerySubscriber + attr_reader :events + + def initialize + @events = [] + end + + def call(_, _, _, _, values) + @events << values[:sql] if values[:sql] =~ /INSERT/ + end + end + + if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + def test_bulk_insert + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + create_fixtures("bulbs") + assert_equal 1, subscriber.events.size, "It takes one INSERT query to insert two fixtures" + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + def test_bulk_insert_multiple_table_with_a_multi_statement_query + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + + create_fixtures("bulbs", "authors", "computers") + + expected_sql = <<~EOS.chop + INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("bulbs")} .* + INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("authors")} .* + INSERT INTO #{ActiveRecord::Base.connection.quote_table_name("computers")} .* + EOS + assert_equal 1, subscriber.events.size + assert_match(/#{expected_sql}/, subscriber.events.first) + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + def test_bulk_insert_with_a_multi_statement_query_raises_an_exception_when_any_insert_fails + require "models/aircraft" + + assert_equal false, Aircraft.columns_hash["wheels_count"].null + fixtures = { + "aircraft" => [ + { "name" => "working_aircrafts", "wheels_count" => 2 }, + { "name" => "broken_aircrafts", "wheels_count" => nil }, + ] + } + + assert_no_difference "Aircraft.count" do + assert_raises(ActiveRecord::NotNullViolation) do + ActiveRecord::Base.connection.insert_fixtures_set(fixtures) + end + end + end + + def test_bulk_insert_with_a_multi_statement_query_in_a_nested_transaction + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, + ] + } + + assert_difference "TrafficLight.count" do + ActiveRecord::Base.transaction do + conn = ActiveRecord::Base.connection + assert_equal 1, conn.open_transactions + conn.insert_fixtures_set(fixtures) + assert_equal 1, conn.open_transactions + end + end + end + end + + if current_adapter?(:Mysql2Adapter) + def test_bulk_insert_with_multi_statements_enabled + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection( + orig_connection.merge(flags: %w[MULTI_STATEMENTS]) + ) + + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, + ] + } + + ActiveRecord::Base.connection.stub(:supports_set_server_option?, false) do + assert_nothing_raised do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + + assert_difference "TrafficLight.count" do + ActiveRecord::Base.transaction do + conn = ActiveRecord::Base.connection + assert_equal 1, conn.open_transactions + conn.insert_fixtures_set(fixtures) + assert_equal 1, conn.open_transactions + end + end + + assert_nothing_raised do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + end + end + end + + def test_bulk_insert_with_multi_statements_disabled + run_without_connection do |orig_connection| + ActiveRecord::Base.establish_connection( + orig_connection.merge(flags: []) + ) + + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a"] }, + ] + } + + ActiveRecord::Base.connection.stub(:supports_set_server_option?, false) do + assert_raises(ActiveRecord::StatementInvalid) do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + + assert_difference "TrafficLight.count" do + conn = ActiveRecord::Base.connection + conn.insert_fixtures_set(fixtures) + end + + assert_raises(ActiveRecord::StatementInvalid) do + conn = ActiveRecord::Base.connection + conn.execute("SELECT 1; SELECT 2;") + conn.raw_connection.abandon_results! + end + end + end + end + + def test_insert_fixtures_set_raises_an_error_when_max_allowed_packet_is_smaller_than_fixtures_set_size + conn = ActiveRecord::Base.connection + mysql_margin = 2 + packet_size = 1024 + bytes_needed_to_have_a_1024_bytes_fixture = 858 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * bytes_needed_to_have_a_1024_bytes_fixture] }, + ] + } + + conn.stub(:max_allowed_packet, packet_size - mysql_margin) do + error = assert_raises(ActiveRecord::ActiveRecordError) { conn.insert_fixtures_set(fixtures) } + assert_match(/Fixtures set is too large #{packet_size}\./, error.message) + end + end + + def test_insert_fixture_set_when_max_allowed_packet_is_bigger_than_fixtures_set_size + conn = ActiveRecord::Base.connection + packet_size = 1024 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 51] }, + ] + } + + conn.stub(:max_allowed_packet, packet_size) do + assert_difference "TrafficLight.count" do + conn.insert_fixtures_set(fixtures) + end + end + end + + def test_insert_fixtures_set_split_the_total_sql_into_two_chunks_smaller_than_max_allowed_packet + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + conn = ActiveRecord::Base.connection + packet_size = 1024 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 450] }, + ], + "comments" => [ + { "post_id" => 1, "body" => "a" * 450 }, + ] + } + + conn.stub(:max_allowed_packet, packet_size) do + conn.insert_fixtures_set(fixtures) + + assert_equal 2, subscriber.events.size + assert_operator subscriber.events.first.bytesize, :<, packet_size + assert_operator subscriber.events.second.bytesize, :<, packet_size + end + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + + def test_insert_fixtures_set_concat_total_sql_into_a_single_packet_smaller_than_max_allowed_packet + subscriber = InsertQuerySubscriber.new + subscription = ActiveSupport::Notifications.subscribe("sql.active_record", subscriber) + conn = ActiveRecord::Base.connection + packet_size = 1024 + fixtures = { + "traffic_lights" => [ + { "location" => "US", "state" => ["NY"], "long_state" => ["a" * 200] }, + ], + "comments" => [ + { "post_id" => 1, "body" => "a" * 200 }, + ] + } + + conn.stub(:max_allowed_packet, packet_size) do + assert_difference ["TrafficLight.count", "Comment.count"], +1 do + conn.insert_fixtures_set(fixtures) + end + end + assert_equal 1, subscriber.events.size + ensure + ActiveSupport::Notifications.unsubscribe(subscription) + end + end + + def test_auto_value_on_primary_key + fixtures = [ + { "name" => "first", "wheels_count" => 2 }, + { "name" => "second", "wheels_count" => 3 } + ] + conn = ActiveRecord::Base.connection + assert_nothing_raised do + conn.insert_fixtures_set({ "aircraft" => fixtures }, ["aircraft"]) + end + result = conn.select_all("SELECT name, wheels_count FROM aircraft ORDER BY id") + assert_equal fixtures, result.to_a + end + + def test_deprecated_insert_fixtures + fixtures = [ + { "name" => "first", "wheels_count" => 2 }, + { "name" => "second", "wheels_count" => 3 } + ] + conn = ActiveRecord::Base.connection + conn.delete("DELETE FROM aircraft") + assert_deprecated do + conn.insert_fixtures(fixtures, "aircraft") + end + result = conn.select_all("SELECT name, wheels_count FROM aircraft ORDER BY id") + assert_equal fixtures, result.to_a + 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_no_args_returns_all + all_topics = topics + assert_equal 5, all_topics.length + assert_equal "The First Topic", all_topics.first["title"] + assert_equal 5, all_topics.last.id + end + + def test_no_args_record_returns_all_without_array + all_binaries = binaries + assert_kind_of(Array, all_binaries) + assert_equal 2, binaries.length + end + + def test_nil_raises + assert_raise(StandardError) { topics(nil) } + assert_raise(StandardError) { topics([nil]) } + 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 + + 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 + + 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 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(nil, "accounts", Account, FIXTURES_ROOT + "/naked/yml/accounts") + end + + def test_empty_yaml_fixture_with_a_comment_in_it + assert_not_nil ActiveRecord::FixtureSet.new(nil, "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_empty Dir[nonexistent_fixture_path + "*"] + + assert_raise(Errno::ENOENT) do + ActiveRecord::FixtureSet.new(nil, "companies", Company, nonexistent_fixture_path) + end + end + + def test_dirty_dirty_yaml_file + fixture_path = FIXTURES_ROOT + "/naked/yml/courses" + error = assert_raise(ActiveRecord::Fixture::FormatError) do + ActiveRecord::FixtureSet.new(nil, "courses", Course, fixture_path) + end + assert_equal "fixture is not a hash: #{fixture_path}.yml", error.to_s + end + + def test_yaml_file_with_one_invalid_fixture + fixture_path = FIXTURES_ROOT + "/naked/yml/courses_with_invalid_key" + error = assert_raise(ActiveRecord::Fixture::FormatError) do + ActiveRecord::FixtureSet.new(nil, "courses", Course, fixture_path) + end + assert_equal "fixture key is not a hash: #{fixture_path}.yml, keys: [\"two\"]", error.to_s + end + + def test_yaml_file_with_invalid_column + e = assert_raise(ActiveRecord::Fixture::FixtureError) do + ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "parrots") + end + + if current_adapter?(:SQLite3Adapter) + assert_equal(%(table "parrots" has no column named "arrr".), e.message) + else + assert_equal(%(table "parrots" has no columns named "arrr", "foobar".), e.message) + end + end + + def test_yaml_file_with_symbol_columns + ActiveRecord::FixtureSet.create_fixtures(FIXTURES_ROOT + "/naked/yml", "trees") + end + + def test_omap_fixtures + assert_nothing_raised do + fixtures = ActiveRecord::FixtureSet.new(nil, "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 + assert_equal data, @binary_helper.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.stub(:configurations, {}) do + 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}" + end + ensure + ENV["DATABASE_URL"] = db_url_tmp + end +end + +class HasManyThroughFixture < ActiveRecord::TestCase + def make_model(name) + Class.new(ActiveRecord::Base) { define_singleton_method(:name) { name } } + end + + def test_has_many_through_with_default_table_name + pt = make_model "ParrotTreasure" + parrot = make_model "Parrot" + treasure = make_model "Treasure" + + pt.table_name = "parrots_treasures" + pt.belongs_to :parrot, anonymous_class: parrot + pt.belongs_to :treasure, anonymous_class: treasure + + parrot.has_many :parrot_treasures, anonymous_class: pt + parrot.has_many :treasures, through: :parrot_treasures + + parrots = File.join FIXTURES_ROOT, "parrots" + + fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots) + rows = fs.table_rows + assert_equal load_has_and_belongs_to_many["parrots_treasures"], rows["parrots_treasures"] + end + + def test_has_many_through_with_renamed_table + pt = make_model "ParrotTreasure" + parrot = make_model "Parrot" + treasure = make_model "Treasure" + + pt.belongs_to :parrot, anonymous_class: parrot + pt.belongs_to :treasure, anonymous_class: treasure + + parrot.has_many :parrot_treasures, anonymous_class: pt + parrot.has_many :treasures, through: :parrot_treasures + + parrots = File.join FIXTURES_ROOT, "parrots" + + fs = ActiveRecord::FixtureSet.new(nil, "parrots", parrot, parrots) + rows = fs.table_rows + assert_equal load_has_and_belongs_to_many["parrots_treasures"], rows["parrot_treasures"] + end + + def test_has_and_belongs_to_many_order + assert_equal ["parrots", "parrots_treasures"], load_has_and_belongs_to_many.keys + 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(nil, "parrots", parrot, parrots) + fs.table_rows + end +end + +if Account.connection.respond_to?(:reset_pk_sequence!) + class FixturesResetPkSequenceTest < ActiveRecord::TestCase + fixtures :accounts + fixtures :companies + self.use_transactional_tests = false + + def setup + @instances = [Account.new(credit_limit: 50), Company.new(name: "RoR Consulting"), Course.new(name: "Test")] + 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_not defined?(@first) + assert_not defined?(@topics) + assert_not defined?(@developers) + assert_not defined?(@accounts) + end + + def test_fixtures_from_root_yml_without_instantiation + assert_not 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 + topic = Struct.new(:title) + assert_equal "The First Topic", topics(:first).title + assert_called(@loaded_fixtures["topics"]["first"], :find, returns: topic.new("Fresh Topic!")) do + assert_equal "Fresh Topic!", topics(:first, true).title + end + 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_not defined?(@first), "@first is not defined" + end +end + +class TransactionalFixturesTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = true + self.use_transactional_tests = 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 FixtureWithSetModelClassTest < ActiveRecord::TestCase + fixtures :other_posts, :other_comments + + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account the +set_model_class+. + self.use_transactional_tests = false + + def test_uses_fixture_class_defined_in_yaml + assert_kind_of Post, other_posts(:second_welcome) + end + + def test_loads_the_associations_to_fixtures_with_set_model_class + post = other_posts(:second_welcome) + comment = other_comments(:second_greetings) + assert_equal [comment], post.comments + assert_equal post, comment.post + end +end + +class SetFixtureClassPrevailsTest < ActiveRecord::TestCase + set_fixture_class bad_posts: Post + fixtures :bad_posts + + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + # and thus takes into account the +set_model_class+. + self.use_transactional_tests = false + + def test_uses_set_fixture_class + assert_kind_of Post, bad_posts(:bad_welcome) + 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_tests = 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_tests = 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_tests = 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_tests = 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_tests = 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 TransactionalFixturesOnConnectionNotification < ActiveRecord::TestCase + self.use_transactional_tests = true + self.use_instantiated_fixtures = false + + def test_transaction_created_on_connection_notification + connection = Class.new do + attr_accessor :pool + + def transaction_open?; end + def begin_transaction(*args); end + def rollback_transaction(*args); end + end.new + + connection.pool = Class.new do + def lock_thread=(lock_thread); end + end.new + + assert_called_with(connection, :begin_transaction, [joinable: false]) do + fire_connection_notification(connection) + end + end + + def test_notification_established_transactions_are_rolled_back + connection = Class.new do + attr_accessor :rollback_transaction_called + attr_accessor :pool + + def transaction_open?; true; end + def begin_transaction(*args); end + def rollback_transaction(*args) + @rollback_transaction_called = true + end + end.new + + connection.pool = Class.new do + def lock_thread=(lock_thread); end + end.new + + fire_connection_notification(connection) + teardown_fixtures + + assert(connection.rollback_transaction_called, "Expected <mock connection>#rollback_transaction to be called but was not") + end + + private + + def fire_connection_notification(connection) + assert_called_with(ActiveRecord::Base.connection_handler, :retrieve_connection, ["book"], returns: connection) do + message_bus = ActiveSupport::Notifications.instrumenter + payload = { + spec_name: "book", + config: nil, + connection_id: connection.object_id + } + + message_bus.instrument("!connection.active_record", payload) { } + end + 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_tests = 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_tests = 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 namespaced/accounts 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 namespaced/accounts people tasks), fixture_table_names.sort + end + ensure + ActiveRecord::FixtureSet.reset_cache + end +end + +class FasterFixturesTest < ActiveRecord::TestCase + self.use_transactional_tests = false + fixtures :categories, :authors, :author_addresses + + 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 + # Set to false to blow away fixtures cache and ensure our fixtures are loaded + self.use_transactional_tests = false + fixtures :parrots, :parrots_pirates, :pirates, :treasures, :mateys, :ships, :computers, + :developers, :"admin/accounts", :"admin/users", :live_parrots, :dead_parrots, :books + + 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_not(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_not(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_label_interpolation_for_integer_label + assert_equal("#1 pirate!", pirates(1).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_supports_sti_with_respective_files + assert_kind_of LiveParrot, live_parrots(:dusty) + assert_kind_of DeadParrot, dead_parrots(:deadbird) + assert_equal pirates(:blackbeard), dead_parrots(:deadbird).killer + end + + def test_namespaced_models + assert_includes admin_accounts(:signals37).users, admin_users(:david) + assert_equal 2, admin_accounts(:signals37).users.size + end + + def test_resolves_enums + assert_predicate books(:awdr), :published? + assert_predicate books(:awdr), :read? + assert_predicate books(:rfr), :proposed? + assert_predicate books(:ddd), :published? + 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 CustomNameForFixtureOrModelTest < ActiveRecord::TestCase + ActiveRecord::FixtureSet.reset_cache + + set_fixture_class :randomly_named_a9 => + ClassNameThatDoesNotFollowCONVENTIONS, + :'admin/randomly_named_a9' => + Admin::ClassNameThatDoesNotFollowCONVENTIONS1, + "admin/randomly_named_b0" => + Admin::ClassNameThatDoesNotFollowCONVENTIONS2 + + 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::ClassNameThatDoesNotFollowCONVENTIONS1, + admin_randomly_named_a9(:first_instance) + assert_kind_of Admin::ClassNameThatDoesNotFollowCONVENTIONS2, + admin_randomly_named_b0(:second_instance) + end + + def test_table_name_is_defined_in_the_model + assert_equal "randomly_named_table2", ActiveRecord::FixtureSet.all_loaded_fixtures["admin/randomly_named_a9"].table_name + assert_equal "randomly_named_table2", Admin::ClassNameThatDoesNotFollowCONVENTIONS1.table_name + end +end + +class FixturesWithDefaultScopeTest < ActiveRecord::TestCase + fixtures :bulbs + + test "inserts fixtures excluded by a default scope" do + assert_equal 1, Bulb.count + assert_equal 2, Bulb.unscoped.count + end + + test "allows access to fixtures excluded by a default scope" do + assert_equal "special", bulbs(:special).name + end +end + +class FixturesWithAbstractBelongsTo < ActiveRecord::TestCase + fixtures :pirates, :doubloons + + test "creates fixtures with belongs_to associations defined in abstract base classes" do + assert_not_nil doubloons(:blackbeards_doubloon) + assert_equal pirates(:blackbeard), doubloons(:blackbeards_doubloon).pirate + end +end + +class FixtureClassNamesTest < ActiveRecord::TestCase + def setup + @saved_cache = fixture_class_names.dup + end + + def teardown + fixture_class_names.replace(@saved_cache) + end + + test "fixture_class_names returns nil for unregistered identifier" do + assert_nil fixture_class_names["unregistered_identifier"] + end +end + +class SameNameDifferentDatabaseFixturesTest < ActiveRecord::TestCase + fixtures :dogs, :other_dogs + + test "fixtures are properly loaded" do + # Force loading the fixtures again to reproduce issue + ActiveRecord::FixtureSet.reset_cache + create_fixtures("dogs", "other_dogs") + + assert_kind_of Dog, dogs(:sophie) + assert_kind_of OtherDog, other_dogs(:lassie) + end +end + +class NilFixturePathTest < ActiveRecord::TestCase + test "raises an error when all fixtures loaded" do + error = assert_raises(StandardError) do + TestCase = Class.new(ActiveRecord::TestCase) + TestCase.class_eval do + self.fixture_path = nil + fixtures :all + end + end + assert_equal <<~MSG.squish, error.message + No fixture path found. + Please set `NilFixturePathTest::TestCase.fixture_path`. + MSG + 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..101fa118c8 --- /dev/null +++ b/activerecord/test/cases/forbidden_attributes_protection_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_support/core_ext/hash/indifferent_access" + +require "models/company" +require "models/person" +require "models/ship" +require "models/ship_part" +require "models/treasure" + +class ProtectedParams + attr_accessor :permitted + alias :permitted? :permitted + + delegate :keys, :key?, :has_key?, :empty?, to: :@parameters + + def initialize(attributes) + @parameters = attributes.with_indifferent_access + @permitted = false + end + + def permit! + @permitted = true + self + end + + def [](key) + @parameters[key] + end + + def to_h + @parameters + end + + def stringify_keys + dup + 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 + + def test_create_with_checks_permitted + params = ProtectedParams.new(first_name: "Guille", gender: "m") + + assert_raises(ActiveModel::ForbiddenAttributesError) do + Person.create_with(params).create! + end + end + + def test_create_with_works_with_permitted_params + params = ProtectedParams.new(first_name: "Guille").permit! + + person = Person.create_with(params).create! + assert_equal "Guille", person.first_name + end + + def test_create_with_works_with_params_values + params = ProtectedParams.new(first_name: "Guille") + + person = Person.create_with(first_name: params[:first_name]).create! + assert_equal "Guille", person.first_name + end + + def test_where_checks_permitted + params = ProtectedParams.new(first_name: "Guille", gender: "m") + + assert_raises(ActiveModel::ForbiddenAttributesError) do + Person.where(params).create! + end + end + + def test_where_works_with_permitted_params + params = ProtectedParams.new(first_name: "Guille").permit! + + person = Person.where(params).create! + assert_equal "Guille", person.first_name + end + + def test_where_works_with_params_values + params = ProtectedParams.new(first_name: "Guille") + + person = Person.where(first_name: params[:first_name]).create! + assert_equal "Guille", person.first_name + end + + def test_where_not_checks_permitted + params = ProtectedParams.new(first_name: "Guille", gender: "m") + + assert_raises(ActiveModel::ForbiddenAttributesError) do + Person.where().not(params) + end + end + + def test_where_not_works_with_permitted_params + params = ProtectedParams.new(first_name: "Guille").permit! + Person.create!(params) + assert_empty Person.where.not(params).select { |p| p.first_name == "Guille" } + end + + def test_strong_params_style_objects_work_with_singular_associations + params = ProtectedParams.new(name: "Stern", ship_attributes: ProtectedParams.new(name: "The Black Rock").permit!).permit! + part = ShipPart.new(params) + + assert_equal "Stern", part.name + assert_equal "The Black Rock", part.ship.name + end + + def test_strong_params_style_objects_work_with_collection_associations + params = ProtectedParams.new( + trinkets_attributes: ProtectedParams.new( + "0" => ProtectedParams.new(name: "Necklace").permit!, + "1" => ProtectedParams.new(name: "Spoon").permit!)).permit! + part = ShipPart.new(params) + + assert_equal "Necklace", part.trinkets[0].name + assert_equal "Spoon", part.trinkets[1].name + 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..9dbd339fe7 --- /dev/null +++ b/activerecord/test/cases/habtm_destroy_order_test.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +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_not_predicate 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 + 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_not_empty ben.reload.lessons + ensure + # get rid of it so Student is still like it was + Student.reset_callbacks(:destroy) + 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_not_empty sicp.reload.students + end +end diff --git a/activerecord/test/cases/helper.rb b/activerecord/test/cases/helper.rb new file mode 100644 index 0000000000..730cd663a2 --- /dev/null +++ b/activerecord/test/cases/helper.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "config" + +require "stringio" + +require "active_record" +require "cases/test_case" +require "active_support/dependencies" +require "active_support/logger" + +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 subsecond_precision_supported? + ActiveRecord::Base.connection.supports_datetime_with_precision? +end + +def mysql_enforcing_gtid_consistency? + current_adapter?(:Mysql2Adapter) && "ON" == ActiveRecord::Base.connection.show_variable("enforce_gtid_consistency") +end + +def supports_default_expression? + if current_adapter?(:PostgreSQLAdapter) + true + elsif current_adapter?(:Mysql2Adapter) + conn = ActiveRecord::Base.connection + !conn.mariadb? && conn.version >= "8.0.13" + end +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} + 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} + 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} + 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_extension!(extension, connection) + return false unless connection.supports_extensions? + return connection.reconnect! if connection.extension_enabled?(extension) + + connection.enable_extension extension + connection.commit_db_transaction if connection.transaction_open? + connection.reconnect! +end + +def disable_extension!(extension, connection) + return false unless connection.supports_extensions? + return true unless connection.extension_enabled?(extension) + + connection.disable_extension extension + connection.reconnect! +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 + + ActiveRecord::FixtureSet.reset_cache +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 diff --git a/activerecord/test/cases/hot_compatibility_test.rb b/activerecord/test/cases/hot_compatibility_test.rb new file mode 100644 index 0000000000..7b388ebc5e --- /dev/null +++ b/activerecord/test/cases/hot_compatibility_test.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/connection_helper" + +class HotCompatibilityTest < ActiveRecord::TestCase + self.use_transactional_tests = false + include ConnectionHelper + + 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 + + if current_adapter?(:PostgreSQLAdapter) && ActiveRecord::Base.connection.prepared_statements + test "cleans up after prepared statement failure in a transaction" do + with_two_connections do |original_connection, ddl_connection| + record = @klass.create! bar: "bar" + + # prepare the reload statement in a transaction + @klass.transaction do + record.reload + end + + assert get_prepared_statement_cache(@klass.connection).any?, + "expected prepared statement cache to have something in it" + + # add a new column + ddl_connection.add_column :hot_compatibilities, :baz, :string + + assert_raise(ActiveRecord::PreparedStatementCacheExpired) do + @klass.transaction do + record.reload + end + end + + assert_empty get_prepared_statement_cache(@klass.connection), + "expected prepared statement cache to be empty but it wasn't" + end + end + + test "cleans up after prepared statement failure in nested transactions" do + with_two_connections do |original_connection, ddl_connection| + record = @klass.create! bar: "bar" + + # prepare the reload statement in a transaction + @klass.transaction do + record.reload + end + + assert get_prepared_statement_cache(@klass.connection).any?, + "expected prepared statement cache to have something in it" + + # add a new column + ddl_connection.add_column :hot_compatibilities, :baz, :string + + assert_raise(ActiveRecord::PreparedStatementCacheExpired) do + @klass.transaction do + @klass.transaction do + @klass.transaction do + record.reload + end + end + end + end + + assert_empty get_prepared_statement_cache(@klass.connection), + "expected prepared statement cache to be empty but it wasn't" + end + end + end + + private + + def get_prepared_statement_cache(connection) + connection.instance_variable_get(:@statements) + .instance_variable_get(:@cache)[Process.pid] + end + + # Rails will automatically clear the prepared statements on the connection + # that runs the migration, so we use two connections to simulate what would + # actually happen on a production system; we'd have one connection running the + # migration from the rake task ("ddl_connection" here), and we'd have another + # connection in a web worker. + def with_two_connections + run_without_connection do |original_connection| + ActiveRecord::Base.establish_connection(original_connection.merge(pool_size: 2)) + begin + ddl_connection = ActiveRecord::Base.connection_pool.checkout + begin + yield original_connection, ddl_connection + ensure + ActiveRecord::Base.connection_pool.checkin ddl_connection + end + ensure + ActiveRecord::Base.clear_all_connections! + end + end + end +end diff --git a/activerecord/test/cases/i18n_test.rb b/activerecord/test/cases/i18n_test.rb new file mode 100644 index 0000000000..22981c142a --- /dev/null +++ b/activerecord/test/cases/i18n_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +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..3d3189900f --- /dev/null +++ b/activerecord/test/cases/inheritance_test.rb @@ -0,0 +1,660 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/company" +require "models/membership" +require "models/person" +require "models/post" +require "models/project" +require "models/subscriber" +require "models/vegetables" +require "models/shop" +require "models/sponsor" + +module InheritanceTestHelper + def with_store_full_sti_class(&block) + assign_store_full_sti_class true, &block + end + + def without_store_full_sti_class(&block) + assign_store_full_sti_class false, &block + end + + def assign_store_full_sti_class(flag) + old_store_full_sti_class = ActiveRecord::Base.store_full_sti_class + ActiveRecord::Base.store_full_sti_class = flag + yield + ensure + ActiveRecord::Base.store_full_sti_class = old_store_full_sti_class + end +end + +class InheritanceTest < ActiveRecord::TestCase + include InheritanceTestHelper + fixtures :companies, :projects, :subscribers, :accounts, :vegetables, :memberships + + def test_class_with_store_full_sti_class_returns_full_name + with_store_full_sti_class do + assert_equal "Namespaced::Company", Namespaced::Company.sti_name + end + 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 + without_store_full_sti_class do + assert_equal "Company", Namespaced::Company.sti_name + end + end + + def test_compute_type_success + assert_equal Author, Company.send(:compute_type, "Author") + end + + def test_compute_type_nonexistent_constant + e = assert_raises NameError do + Company.send :compute_type, "NonexistentModel" + end + assert_equal "uninitialized constant Company::NonexistentModel", e.message + assert_equal "Company::NonexistentModel", e.name + end + + def test_compute_type_no_method_error + ActiveSupport::Dependencies.stub(:safe_constantize, proc { raise NoMethodError }) do + assert_raises NoMethodError do + Company.send :compute_type, "InvalidModel" + end + 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.stub(:safe_constantize, proc { raise e }) do + exception = assert_raises NameError do + Company.send :compute_type, "InvalidModel" + end + assert_equal error.message, exception.message + end + end + + def test_compute_type_argument_error + ActiveSupport::Dependencies.stub(:safe_constantize, proc { raise ArgumentError }) do + assert_raises ArgumentError do + Company.send :compute_type, "InvalidModel" + end + end + end + + def test_should_store_demodulized_class_name_with_store_full_sti_class_option_disabled + without_store_full_sti_class do + item = Namespaced::Company.new + assert_equal "Company", item[:type] + end + end + + def test_should_store_full_class_name_with_store_full_sti_class_option_enabled + with_store_full_sti_class do + item = Namespaced::Company.new + assert_equal "Namespaced::Company", item[:type] + end + end + + def test_different_namespace_subclass_should_load_correctly_with_store_full_sti_class_option + with_store_full_sti_class do + item = Namespaced::Company.create name: "Wolverine 2" + assert_not_nil Company.find(item.id) + assert_not_nil Namespaced::Company.find(item.id) + end + end + + def test_descends_from_active_record + assert_not_predicate ActiveRecord::Base, :descends_from_active_record? + + # Abstract subclass of AR::Base. + assert_predicate LoosePerson, :descends_from_active_record? + + # Concrete subclass of an abstract class. + assert_predicate LooseDescendant, :descends_from_active_record? + + # Concrete subclass of AR::Base. + assert_predicate TightPerson, :descends_from_active_record? + + # Concrete subclass of a concrete class but has no type column. + assert_predicate TightDescendant, :descends_from_active_record? + + # Concrete subclass of AR::Base. + assert_predicate Post, :descends_from_active_record? + + # Concrete subclasses of a concrete class which has a type column. + assert_not_predicate StiPost, :descends_from_active_record? + assert_not_predicate SubStiPost, :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_not_predicate AbstractStiPost, :descends_from_active_record? + + # Concrete subclass of an abstract class which has a type column. + assert_not_predicate SubAbstractStiPost, :descends_from_active_record? + end + + def test_company_descends_from_active_record + assert_not_predicate 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_not Class.new(Company).descends_from_active_record?, "Company subclass should not descend from ActiveRecord::Base" + end + + def test_abstract_class + assert_not_predicate ActiveRecord::Base, :abstract_class? + assert_predicate LoosePerson, :abstract_class? + assert_not_predicate LooseDescendant, :abstract_class? + end + + def test_inheritance_base_class + assert_equal Post, Post.base_class + assert_predicate Post, :base_class? + assert_equal Post, SpecialPost.base_class + assert_not_predicate SpecialPost, :base_class? + assert_equal Post, StiPost.base_class + assert_not_predicate StiPost, :base_class? + assert_equal Post, SubStiPost.base_class + assert_not_predicate SubStiPost, :base_class? + assert_equal SubAbstractStiPost, SubAbstractStiPost.base_class + assert_predicate SubAbstractStiPost, :base_class? + end + + def test_abstract_inheritance_base_class + assert_equal LoosePerson, LoosePerson.base_class + assert_predicate LoosePerson, :base_class? + assert_equal LooseDescendant, LooseDescendant.base_class + assert_predicate LooseDescendant, :base_class? + assert_equal TightPerson, TightPerson.base_class + assert_predicate TightPerson, :base_class? + assert_equal TightPerson, TightDescendant.base_class + assert_not_predicate 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_becomes_and_change_tracking_for_inheritance_columns + cucumber = Vegetable.find(1) + cabbage = cucumber.becomes!(Cabbage) + assert_equal ["Cucumber", "Cabbage"], cabbage.custom_type_change + 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_where_new_with_subclass + firm = Company.where(type: "Firm").new + assert_equal Firm, firm.class + end + + def test_where_create_with_subclass + firm = Company.where(type: "Firm").create(name: "Basecamp") + assert_equal Firm, firm.class + end + + def test_where_create_bang_with_subclass + firm = Company.where(type: "Firm").create!(name: "Basecamp") + 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_where_new_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").new } + end + + def test_where_new_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").new } + end + + def test_where_create_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").create } + end + + def test_where_create_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").create } + end + + def test_where_create_bang_with_invalid_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "InvalidType").create! } + end + + def test_where_create_bang_with_unrelated_type + assert_raise(ActiveRecord::SubclassNotFound) { Company.where(type: "Account").create! } + end + + def test_new_with_unrelated_namespaced_type + without_store_full_sti_class do + e = assert_raises ActiveRecord::SubclassNotFound do + Namespaced::Company.new(type: "Firm") + end + + assert_equal "Invalid single-table inheritance type: Namespaced::Firm is not a subclass of Namespaced::Company", e.message + end + end + + def test_new_with_complex_inheritance + assert_nothing_raised { Client.new(type: "VerySpecialClient") } + end + + def test_new_without_storing_full_sti_class + without_store_full_sti_class do + item = Company.new(type: "SpecialCo") + assert_instance_of Company::SpecialCo, item + end + end + + def test_new_with_autoload_paths + path = File.expand_path("../models/autoloadable", __dir__) + 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(:firm).loaded?, "association was not eager loaded" + end + + def test_alt_eager_loading + cabbage = RedCabbage.all.merge!(includes: :seller).find(4) + assert cabbage.association(:seller).loaded?, "association was not eager loaded" + end + + def test_eager_load_belongs_to_primary_key_quoting + con = Account.connection + bind_param = Arel::Nodes::BindParam.new(nil) + assert_sql(/#{con.quote_table_name('companies')}\.#{con.quote_column_name('id')} = (?:#{Regexp.quote(bind_param.to_sql)}|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 + + def test_inheritance_with_default_scope + assert_equal 1, SelectedMembership.count(:all) + end +end + +class InheritanceComputeTypeTest < ActiveRecord::TestCase + include InheritanceTestHelper + fixtures :companies + + teardown do + self.class.const_remove :FirmOnTheFly rescue nil + Firm.const_remove :FirmOnTheFly rescue nil + end + + def test_instantiation_doesnt_try_to_require_corresponding_file + without_store_full_sti_class do + 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) } + end + 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 + + def test_inheritance_new_with_subclass_as_default + original_type = Company.columns_hash["type"].default + ActiveRecord::Base.connection.change_column_default :companies, :type, "Firm" + Company.reset_column_information + + firm = Company.new # without arguments + assert_equal "Firm", firm.type + assert_instance_of Firm, firm + + firm = Company.new(firm_name: "Shri Hans Plastic") # with arguments + assert_equal "Firm", firm.type + assert_instance_of Firm, firm + + client = Client.new + assert_equal "Client", client.type + assert_instance_of Client, client + + firm = Company.new(type: "Client") # overwrite the default type + assert_equal "Client", firm.type + assert_instance_of Client, firm + ensure + ActiveRecord::Base.connection.change_column_default :companies, :type, original_type + Company.reset_column_information + end +end + +class InheritanceAttributeTest < ActiveRecord::TestCase + class Company < ActiveRecord::Base + self.table_name = "companies" + attribute :type, :string, default: "InheritanceAttributeTest::Startup" + end + + class Startup < Company + end + + class Empire < Company + end + + def test_inheritance_new_with_subclass_as_default + startup = Company.new # without arguments + assert_equal "InheritanceAttributeTest::Startup", startup.type + assert_instance_of Startup, startup + + empire = Company.new(type: "InheritanceAttributeTest::Empire") # without arguments + assert_equal "InheritanceAttributeTest::Empire", empire.type + assert_instance_of Empire, empire + end +end + +class InheritanceAttributeMappingTest < ActiveRecord::TestCase + setup do + @old_registry = ActiveRecord::Type.registry + ActiveRecord::Type.registry = ActiveRecord::Type::AdapterSpecificRegistry.new + ActiveRecord::Type.register :omg_sti, InheritanceAttributeMappingTest::OmgStiType + Company.delete_all + Sponsor.delete_all + end + + teardown do + ActiveRecord::Type.registry = @old_registry + end + + class OmgStiType < ActiveRecord::Type::String + def cast_value(value) + if value =~ /\Aomg_(.+)\z/ + $1.classify + else + value + end + end + + def serialize(value) + if value + "omg_%s" % value.underscore + end + end + end + + class Company < ActiveRecord::Base + self.table_name = "companies" + attribute :type, :omg_sti + end + + class Startup < Company; end + class Empire < Company; end + + class Sponsor < ActiveRecord::Base + self.table_name = "sponsors" + attribute :sponsorable_type, :omg_sti + + belongs_to :sponsorable, polymorphic: true + end + + def test_sti_with_custom_type + Startup.create! name: "a Startup" + Empire.create! name: "an Empire" + + assert_equal [["a Startup", "omg_inheritance_attribute_mapping_test/startup"], + ["an Empire", "omg_inheritance_attribute_mapping_test/empire"]], ActiveRecord::Base.connection.select_rows("SELECT name, type FROM companies").sort + assert_equal [["a Startup", "InheritanceAttributeMappingTest::Startup"], + ["an Empire", "InheritanceAttributeMappingTest::Empire"]], Company.all.map { |a| [a.name, a.type] }.sort + + startup = Startup.first + startup.becomes! Empire + startup.save! + + assert_equal [["a Startup", "omg_inheritance_attribute_mapping_test/empire"], + ["an Empire", "omg_inheritance_attribute_mapping_test/empire"]], ActiveRecord::Base.connection.select_rows("SELECT name, type FROM companies").sort + + assert_equal [["a Startup", "InheritanceAttributeMappingTest::Empire"], + ["an Empire", "InheritanceAttributeMappingTest::Empire"]], Company.all.map { |a| [a.name, a.type] }.sort + end + + def test_polymorphic_associations_custom_type + startup = Startup.create! name: "a Startup" + sponsor = Sponsor.create! sponsorable: startup + + assert_equal ["omg_inheritance_attribute_mapping_test/company"], ActiveRecord::Base.connection.select_values("SELECT sponsorable_type FROM sponsors") + + sponsor = Sponsor.first + assert_equal startup, sponsor.sponsorable + end +end diff --git a/activerecord/test/cases/instrumentation_test.rb b/activerecord/test/cases/instrumentation_test.rb new file mode 100644 index 0000000000..c09ea32991 --- /dev/null +++ b/activerecord/test/cases/instrumentation_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" + +module ActiveRecord + class InstrumentationTest < ActiveRecord::TestCase + def setup + ActiveRecord::Base.connection.schema_cache.add(Book.table_name) + end + + def test_payload_name_on_load + Book.create(name: "test book") + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "SELECT" + assert_equal "Book Load", event.payload[:name] + end + end + Book.first + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_create + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "INSERT" + assert_equal "Book Create", event.payload[:name] + end + end + Book.create(name: "test book") + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_update + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "UPDATE" + assert_equal "Book Update", event.payload[:name] + end + end + book = Book.create(name: "test book") + book.update_attribute(:name, "new name") + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_update_all + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "UPDATE" + assert_equal "Book Update All", event.payload[:name] + end + end + Book.create(name: "test book") + Book.update_all(name: "new name") + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + + def test_payload_name_on_destroy + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |*args| + event = ActiveSupport::Notifications::Event.new(*args) + if event.payload[:sql].match "DELETE" + assert_equal "Book Destroy", event.payload[:name] + end + end + book = Book.create(name: "test book") + book.destroy + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber + end + end +end diff --git a/activerecord/test/cases/integration_test.rb b/activerecord/test/cases/integration_test.rb new file mode 100644 index 0000000000..5687afbc71 --- /dev/null +++ b/activerecord/test/cases/integration_test.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/company" +require "models/developer" +require "models/computer" +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_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_words_properly + firm = Firm.find(4) + firm.name << ", Inc." + assert_equal "4-flamboyant-software", firm.to_param + end + + def test_to_param_class_method_truncates_after_parameterize + firm = Firm.find(4) + firm.name = "Huey, Dewey, & Louie LLC" + # 123456789T123456789v + assert_equal "4-huey-dewey-louie-llc", firm.to_param + end + + def test_to_param_class_method_truncates_after_parameterize_with_hyphens + firm = Firm.find(4) + firm.name = "Door-to-Door Wash-n-Fold Service" + # 123456789T123456789v + assert_equal "4-door-to-door-wash-n", 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-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_truncates_case_shown_in_doc + firm = Firm.find(4) + firm.name = "David Heinemeier Hansson" + assert_equal "4-david-heinemeier", 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-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_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(:usec)}", 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 + + travel(1.second) do + assert pet.touch + end + 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(:usec)}", 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(:usec)}", 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(:usec)}", dev.cache_key + end + + def test_cache_key_format_is_precise_enough + skip("Subsecond precision is not supported") unless subsecond_precision_supported? + dev = Developer.first + key = dev.cache_key + travel_to dev.updated_at + 0.000001 do + dev.touch + end + assert_not_equal key, dev.cache_key + end + + def test_cache_key_format_is_not_too_precise + dev = Developer.first + dev.touch + key = dev.cache_key + assert_equal key, dev.reload.cache_key + end + + def test_cache_version_format_is_precise_enough + skip("Subsecond precision is not supported") unless subsecond_precision_supported? + with_cache_versioning do + dev = Developer.first + version = dev.cache_version.to_param + travel_to Developer.first.updated_at + 0.000001 do + dev.touch + end + assert_not_equal version, dev.cache_version.to_param + end + end + + def test_cache_version_format_is_not_too_precise + with_cache_versioning do + dev = Developer.first + dev.touch + key = dev.cache_version.to_param + assert_equal key, dev.reload.cache_version.to_param + end + end + + def test_named_timestamps_for_cache_key + assert_deprecated do + owner = owners(:blackbeard) + assert_equal "owners/#{owner.id}-#{owner.happy_at.utc.to_s(:usec)}", owner.cache_key(:updated_at, :happy_at) + end + end + + def test_cache_key_when_named_timestamp_is_nil + assert_deprecated do + owner = owners(:blackbeard) + owner.happy_at = nil + assert_equal "owners/#{owner.id}", owner.cache_key(:happy_at) + end + end + + def test_cache_key_is_stable_with_versioning_on + with_cache_versioning do + developer = Developer.first + first_key = developer.cache_key + + developer.touch + second_key = developer.cache_key + + assert_equal first_key, second_key + end + end + + def test_cache_version_changes_with_versioning_on + with_cache_versioning do + developer = Developer.first + first_version = developer.cache_version + + travel 10.seconds do + developer.touch + end + + second_version = developer.cache_version + + assert_not_equal first_version, second_version + end + end + + def test_cache_key_retains_version_when_custom_timestamp_is_used + with_cache_versioning do + developer = Developer.first + first_key = developer.cache_key_with_version + + travel 10.seconds do + developer.touch + end + + second_key = developer.cache_key_with_version + + assert_not_equal first_key, second_key + end + end + + def with_cache_versioning(value = true) + @old_cache_versioning = ActiveRecord::Base.cache_versioning + ActiveRecord::Base.cache_versioning = value + yield + ensure + ActiveRecord::Base.cache_versioning = @old_cache_versioning + 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..a1be9c2780 --- /dev/null +++ b/activerecord/test/cases/invalid_connection_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "cases/helper" + +if current_adapter?(:Mysql2Adapter) + class TestAdapterWithInvalidConnection < ActiveRecord::TestCase + self.use_transactional_tests = 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: "mysql2", 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 +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..6cf17ac15d --- /dev/null +++ b/activerecord/test/cases/invertible_migration_test.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Horse < ActiveRecord::Base +end + +module ActiveRecord + class InvertibleMigrationTest < ActiveRecord::TestCase + class SilentMigration < ActiveRecord::Migration::Current + 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 InvertibleTransactionMigration < InvertibleMigration + def change + transaction do + super + 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 ChangeColumnDefault1 < SilentMigration + def change + create_table("horses") do |t| + t.column :name, :string, default: "Sekitoba" + end + end + end + + class ChangeColumnDefault2 < SilentMigration + def change + change_column_default :horses, :name, from: "Sekitoba", to: "Diomed" + end + end + + class DisableExtension1 < SilentMigration + def change + enable_extension "hstore" + end + end + + class DisableExtension2 < SilentMigration + def change + disable_extension "hstore" + end + end + + class LegacyMigration < ActiveRecord::Migration::Current + 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 + + class RevertCustomForeignKeyTable < SilentMigration + def change + change_table(:horses) do |t| + t.references :owner, foreign_key: { to_table: :developers } + end + end + end + + class UpOnlyMigration < SilentMigration + def change + add_column :horses, :oldie, :integer, default: 0 + up_only { execute "update horses set oldie = 1" } + end + end + + self.use_transactional_tests = false + + setup do + @verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false + 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 + ActiveRecord::Migration.verbose = @verbose_was + 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 + index_definition = ["horses", [:name, :color]] + migration1 = RemoveIndexMigration1.new + migration1.migrate(:up) + assert migration1.connection.index_exists?(*index_definition) + + migration2 = RemoveIndexMigration2.new + migration2.migrate(:up) + assert_not migration2.connection.index_exists?(*index_definition) + + migration2.migrate(:down) + assert migration2.connection.index_exists?(*index_definition) + 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_not migration.connection.table_exists?("horses") + end + + def test_migrate_revert + migration = InvertibleMigration.new + revert = InvertibleRevertMigration.new + migration.migrate :up + revert.migrate :up + assert_not migration.connection.table_exists?("horses") + revert.migrate :down + assert migration.connection.table_exists?("horses") + migration.migrate :down + assert_not 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_not 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_not 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_not migration.connection.table_exists?("horses") + revert.migrate :down + assert migration.connection.table_exists?("horses") + migration.migrate :down + assert_not 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_not revert.connection.table_exists?("horses") + end + + def test_migrate_revert_transaction + migration = InvertibleTransactionMigration.new + migration.migrate :up + assert migration.connection.table_exists?("horses") + migration.migrate :down + assert_not migration.connection.table_exists?("horses") + end + + def test_migrate_revert_change_column_default + migration1 = ChangeColumnDefault1.new + migration1.migrate(:up) + assert_equal "Sekitoba", Horse.new.name + + migration2 = ChangeColumnDefault2.new + migration2.migrate(:up) + Horse.reset_column_information + assert_equal "Diomed", Horse.new.name + + migration2.migrate(:down) + Horse.reset_column_information + assert_equal "Sekitoba", Horse.new.name + end + + if current_adapter?(:PostgreSQLAdapter) + def test_migrate_enable_and_disable_extension + migration1 = InvertibleMigration.new + migration2 = DisableExtension1.new + migration3 = DisableExtension2.new + + migration1.migrate(:up) + migration2.migrate(:up) + assert_equal true, Horse.connection.extension_enabled?("hstore") + + migration3.migrate(:up) + assert_equal false, Horse.connection.extension_enabled?("hstore") + + migration3.migrate(:down) + assert_equal true, Horse.connection.extension_enabled?("hstore") + + migration2.migrate(:down) + assert_equal false, Horse.connection.extension_enabled?("hstore") + ensure + enable_extension!("hstore", ActiveRecord::Base.connection) + end + 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_not 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_not 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_not 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 + + def test_migrations_can_handle_foreign_keys_to_specific_tables + migration = RevertCustomForeignKeyTable.new + InvertibleMigration.migrate(:up) + migration.migrate(:up) + migration.migrate(:down) + end + + # MySQL 5.7 and Oracle do not allow to create duplicate indexes on the same columns + unless current_adapter?(: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_not connection.index_exists?(:horses, :content, name: "horses_index_named"), + "horses_index_named index should not exist" + end + end + + def test_up_only + InvertibleMigration.new.migrate(:up) + horse1 = Horse.create + # populates existing horses with oldie = 1 but new ones have default 0 + UpOnlyMigration.new.migrate(:up) + Horse.reset_column_information + horse1.reload + horse2 = Horse.create + + assert 1, horse1.oldie # created before migration + assert 0, horse2.oldie # created after migration + + UpOnlyMigration.new.migrate(:down) # should be no error + connection = ActiveRecord::Base.connection + assert_not connection.column_exists?(:horses, :oldie) + Horse.reset_column_information + end + end +end diff --git a/activerecord/test/cases/json_attribute_test.rb b/activerecord/test/cases/json_attribute_test.rb new file mode 100644 index 0000000000..afc39d0420 --- /dev/null +++ b/activerecord/test/cases/json_attribute_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/json_shared_test_cases" + +class JsonAttributeTest < ActiveRecord::TestCase + include JSONSharedTestCases + self.use_transactional_tests = false + + class JsonDataTypeOnText < ActiveRecord::Base + self.table_name = "json_data_type" + + attribute :payload, :json + attribute :settings, :json + + store_accessor :settings, :resolution + end + + def setup + super + @connection.create_table("json_data_type") do |t| + t.string "payload" + t.string "settings" + end + end + + private + def column_type + :string + end + + def klass + JsonDataTypeOnText + 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..82cf281cff --- /dev/null +++ b/activerecord/test/cases/json_serialization_test.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +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_includes json, %("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_includes json, %("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_not_includes json, %("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_includes json, %("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_frozen_hash + def @contact.serializable_hash(options = nil) + super({ only: %w(name) }.freeze) + 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_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 }.freeze + assert_nothing_raised { @contact.serializable_hash(options) } + end +end + +class DatabaseConnectedJsonEncodingTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :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_not_respond_to @david.posts.first, :favorite_quote + assert_match %r{"favorite_quote":"Constraints are liberating"}, json + assert_equal 1, %r{"favorite_quote":}.match(json).size + 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_includes json, 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/json_shared_test_cases.rb b/activerecord/test/cases/json_shared_test_cases.rb new file mode 100644 index 0000000000..9b79803503 --- /dev/null +++ b/activerecord/test/cases/json_shared_test_cases.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "support/schema_dumping_helper" + +module JSONSharedTestCases + include SchemaDumpingHelper + + class JsonDataType < ActiveRecord::Base + self.table_name = "json_data_type" + + store_accessor :settings, :resolution + end + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table :json_data_type, if_exists: true + klass.reset_column_information + end + + def test_column + column = klass.columns_hash["payload"] + assert_equal column_type, column.type + assert_type_match column_type, column.sql_type + + type = klass.type_for_attribute("payload") + assert_not_predicate type, :binary? + end + + def test_change_table_supports_json + @connection.change_table("json_data_type") do |t| + t.public_send column_type, "users" + end + klass.reset_column_information + column = klass.columns_hash["users"] + assert_equal column_type, column.type + assert_type_match column_type, column.sql_type + end + + def test_schema_dumping + output = dump_table_schema("json_data_type") + assert_match(/t\.#{column_type}\s+"settings"/, output) + end + + def test_cast_value_on_write + x = klass.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 + type = klass.type_for_attribute("payload") + + data = '{"a_key":"a_value"}' + hash = type.deserialize(data) + assert_equal({ "a_key" => "a_value" }, hash) + assert_equal({ "a_key" => "a_value" }, type.deserialize(data)) + + assert_equal({}, type.deserialize("{}")) + assert_equal({ "key" => nil }, type.deserialize('{"key": null}')) + assert_equal({ "c" => "}", '"a"' => 'b "a b' }, type.deserialize(%q({"c":"}", "\"a\"":"b \"a b"}))) + end + + def test_rewrite + @connection.execute(insert_statement_per_database('{"k":"v"}')) + x = klass.first + x.payload = { '"a\'' => "b" } + assert x.save! + end + + def test_select + @connection.execute(insert_statement_per_database('{"k":"v"}')) + x = klass.first + assert_equal({ "k" => "v" }, x.payload) + end + + def test_select_multikey + @connection.execute(insert_statement_per_database('{"k1":"v1", "k2":"v2", "k3":[1,2,3]}')) + x = klass.first + assert_equal({ "k1" => "v1", "k2" => "v2", "k3" => [1, 2, 3] }, x.payload) + end + + def test_null_json + @connection.execute(insert_statement_per_database("null")) + x = klass.first + assert_nil(x.payload) + end + + def test_select_nil_json_after_create + json = klass.create!(payload: nil) + x = klass.where(payload: nil).first + assert_equal(json, x) + end + + def test_select_nil_json_after_update + json = klass.create!(payload: "foo") + x = klass.where(payload: nil).first + assert_nil(x) + + json.update(payload: nil) + x = klass.where(payload: nil).first + assert_equal(json.reload, x) + end + + def test_select_array_json_value + @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]')) + x = klass.first + assert_equal(["v0", { "k1" => "v1" }], x.payload) + end + + def test_rewrite_array_json_value + @connection.execute(insert_statement_per_database('["v0",{"k1":"v1"}]')) + x = klass.first + x.payload = ["v1", { "k2" => "v2" }, "v3"] + assert x.save! + end + + def test_with_store_accessors + x = klass.new(resolution: "320×480") + assert_equal "320×480", x.resolution + + x.save! + x = klass.first + assert_equal "320×480", x.resolution + + x.resolution = "640×1136" + x.save! + + x = klass.first + assert_equal "640×1136", x.resolution + end + + def test_duplication_with_store_accessors + x = klass.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 = klass.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 = klass.new + assert_not_predicate json, :changed? + + json.payload = { "one" => "two" } + assert_predicate json, :changed? + assert_predicate json, :payload_changed? + + json.save! + assert_not_predicate json, :changed? + + json.payload["three"] = "four" + assert_predicate json, :payload_changed? + + json.save! + json.reload + + assert_equal({ "one" => "two", "three" => "four" }, json.payload) + assert_not_predicate json, :changed? + end + + def test_changes_in_place_ignores_key_order + json = klass.new + assert_not_predicate json, :changed? + + json.payload = { "three" => "four", "one" => "two" } + json.save! + json.reload + + json.payload = { "three" => "four", "one" => "two" } + assert_not_predicate json, :changed? + + json.payload = [{ "three" => "four", "one" => "two" }, { "seven" => "eight", "five" => "six" }] + json.save! + json.reload + + json.payload = [{ "three" => "four", "one" => "two" }, { "seven" => "eight", "five" => "six" }] + assert_not_predicate json, :changed? + end + + def test_changes_in_place_with_ruby_object + time = Time.now.utc + json = klass.create!(payload: time) + + json.reload + assert_not_predicate json, :changed? + + json.payload = time + assert_not_predicate json, :changed? + end + + def test_assigning_string_literal + json = klass.create!(payload: "foo") + assert_equal "foo", json.payload + end + + def test_assigning_number + json = klass.create!(payload: 1.234) + assert_equal 1.234, json.payload + end + + def test_assigning_boolean + json = klass.create!(payload: true) + assert_equal true, json.payload + end + + def test_not_compatible_with_serialize_json + new_klass = Class.new(klass) do + serialize :payload, JSON + end + assert_raises(ActiveRecord::AttributeMethods::Serialization::ColumnNotSerializableError) do + new_klass.new + end + end + + class MySettings + 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 + + def test_json_with_serialized_attributes + new_klass = Class.new(klass) do + serialize :settings, MySettings + end + + new_klass.create!(settings: MySettings.new("one" => "two")) + record = new_klass.first + + assert_instance_of MySettings, record.settings + assert_equal({ "one" => "two" }, record.settings.to_hash) + + record.settings = MySettings.new("three" => "four") + record.save! + + assert_equal({ "three" => "four" }, record.reload.settings.to_hash) + end + + private + def klass + JsonDataType + end + + def assert_type_match(type, sql_type) + native_type = ActiveRecord::Base.connection.native_database_types[type][:name] + assert_match %r(\A#{native_type}\b), sql_type + end + + def insert_statement_per_database(values) + if current_adapter?(:OracleAdapter) + "insert into json_data_type (id, payload) VALUES (json_data_type_seq.nextval, '#{values}')" + else + "insert into json_data_type (payload) VALUES ('#{values}')" + end + end +end diff --git a/activerecord/test/cases/legacy_configurations_test.rb b/activerecord/test/cases/legacy_configurations_test.rb new file mode 100644 index 0000000000..c36feb5116 --- /dev/null +++ b/activerecord/test/cases/legacy_configurations_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class LegacyConfigurationsTest < ActiveRecord::TestCase + def test_can_turn_configurations_into_a_hash + assert ActiveRecord::Base.configurations.to_h.is_a?(Hash), "expected to be a hash but was not." + assert_equal ["arunit", "arunit2", "arunit_without_prepared_statements"].sort, ActiveRecord::Base.configurations.to_h.keys.sort + end + + def test_each_is_deprecated + assert_deprecated do + ActiveRecord::Base.configurations.each do |db_config| + assert_equal "primary", db_config.spec_name + end + end + end + + def test_first_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_fetch_is_deprecated + assert_deprecated do + db_config = ActiveRecord::Base.configurations.fetch("arunit").first + assert_equal "arunit", db_config.env_name + assert_equal "primary", db_config.spec_name + end + end + + def test_values_are_deprecated + config_hashes = ActiveRecord::Base.configurations.configurations.map(&:config) + assert_deprecated do + assert_equal config_hashes, ActiveRecord::Base.configurations.values + end + 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..33bd74e114 --- /dev/null +++ b/activerecord/test/cases/locking_test.rb @@ -0,0 +1,738 @@ +# frozen_string_literal: true + +require "thread" +require "cases/helper" +require "models/person" +require "models/job" +require "models/reader" +require "models/ship" +require "models/legacy_thing" +require "models/personal_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" +require "models/frog" + +class LockWithoutDefault < ActiveRecord::Base; end + +class LockWithCustomColumnWithoutDefault < ActiveRecord::Base + self.table_name = :lock_without_defaults_cust + 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 + + 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_predicate s1, :frozen? + assert_predicate 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_predicate p1, :frozen? + assert_predicate 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_when_explicitly_passing_nil + p1 = Person.new(first_name: "anika", lock_version: nil) + p1.save! + assert_equal 0, p1.lock_version + end + + def test_lock_new_when_explicitly_passing_value + p1 = Person.new(first_name: "Douglas Adams", lock_version: 42) + p1.save! + assert_equal 42, 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 + assert_not p1.changed?, "Changes should have been cleared" + end + + def test_touch_stale_object + person = Person.create!(first_name: "Mehmet Emin") + stale_person = Person.find(person.id) + person.update_attribute(:gender, "M") + + assert_raises(ActiveRecord::StaleObjectError) do + stale_person.touch + end + end + + def test_update_with_dirty_primary_key + assert_raises(ActiveRecord::RecordNotUnique) do + person = Person.find(1) + person.id = 2 + person.save! + end + + person = Person.find(1) + person.id = 42 + person.save! + + assert Person.find(42) + assert_raises(ActiveRecord::RecordNotFound) do + Person.find(1) + end + end + + def test_delete_with_dirty_primary_key + person = Person.find(1) + person.id = 2 + person.delete + + assert Person.find(2) + assert_raises(ActiveRecord::RecordNotFound) do + Person.find(1) + end + end + + def test_destroy_with_dirty_primary_key + person = Person.find(1) + person.id = 2 + person.destroy + + assert Person.find(2) + assert_raises(ActiveRecord::RecordNotFound) do + Person.find(1) + end + end + + def test_explicit_update_lock_column_raise_error + person = Person.find(1) + + assert_raises(ActiveRecord::StaleObjectError) do + person.first_name = "Douglas Adams" + person.lock_version = 42 + + assert_predicate person, :lock_version_changed? + + person.save + end + 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 + assert_nil t1.lock_version_before_type_cast + + t1.save! + t1.reload + + assert_equal 0, t1.lock_version + assert_equal 0, t1.lock_version_before_type_cast + end + + def test_touch_existing_lock_without_default_should_work_with_null_in_the_database + ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')") + t1 = LockWithoutDefault.last + + assert_equal 0, t1.lock_version + assert_nil t1.lock_version_before_type_cast + + t1.touch + + assert_equal 1, t1.lock_version + end + + def test_touch_stale_object_with_lock_without_default + t1 = LockWithoutDefault.create!(title: "title1") + stale_object = LockWithoutDefault.find(t1.id) + + t1.update!(title: "title2") + + assert_raises(ActiveRecord::StaleObjectError) do + stale_object.touch + end + end + + def test_lock_without_default_should_work_with_null_in_the_database + ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')") + t1 = LockWithoutDefault.last + t2 = LockWithoutDefault.find(t1.id) + + assert_equal 0, t1.lock_version + assert_nil t1.lock_version_before_type_cast + assert_equal 0, t2.lock_version + assert_nil t2.lock_version_before_type_cast + + t1.title = "new title1" + t2.title = "new title2" + + assert_nothing_raised { t1.save! } + assert_equal 1, t1.lock_version + assert_equal "new title1", t1.title + + assert_raise(ActiveRecord::StaleObjectError) { t2.save! } + assert_equal 0, t2.lock_version + assert_equal "new title2", t2.title + end + + def test_lock_without_default_queries_count + t1 = LockWithoutDefault.create(title: "title1") + + assert_equal "title1", t1.title + assert_equal 0, t1.lock_version + + assert_queries(1) { t1.update(title: "title2") } + + t1.reload + assert_equal "title2", t1.title + assert_equal 1, t1.lock_version + + t2 = LockWithoutDefault.new(title: "title1") + + assert_queries(1) { t2.save! } + + t2.reload + assert_equal "title1", t2.title + assert_equal 0, t2.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 + assert_nil t1.custom_lock_version_before_type_cast + + t1.save! + t1.reload + + assert_equal 0, t1.custom_lock_version + assert_equal 0, t1.custom_lock_version_before_type_cast + end + + def test_lock_with_custom_column_without_default_should_work_with_null_in_the_database + ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults_cust(title) VALUES('title1')") + + t1 = LockWithCustomColumnWithoutDefault.last + t2 = LockWithCustomColumnWithoutDefault.find(t1.id) + + assert_equal 0, t1.custom_lock_version + assert_nil t1.custom_lock_version_before_type_cast + assert_equal 0, t2.custom_lock_version + assert_nil t2.custom_lock_version_before_type_cast + + t1.title = "new title1" + t2.title = "new title2" + + assert_nothing_raised { t1.save! } + assert_equal 1, t1.custom_lock_version + assert_equal "new title1", t1.title + + assert_raise(ActiveRecord::StaleObjectError) { t2.save! } + assert_equal 0, t2.custom_lock_version + assert_equal "new title2", t2.title + end + + def test_lock_with_custom_column_without_default_queries_count + t1 = LockWithCustomColumnWithoutDefault.create(title: "title1") + + assert_equal "title1", t1.title + assert_equal 0, t1.custom_lock_version + + assert_queries(1) { t1.update(title: "title2") } + + t1.reload + assert_equal "title2", t1.title + assert_equal 1, t1.custom_lock_version + + t2 = LockWithCustomColumnWithoutDefault.new(title: "title1") + + assert_queries(1) { t2.save! } + + t2.reload + assert_equal "title1", t2.title + assert_equal 0, t2.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_counter_cache_with_touch_and_lock_version + car = Car.create! + + assert_equal 0, car.wheels_count + assert_equal 0, car.lock_version + + previously_updated_at = car.updated_at + previously_wheels_owned_at = car.wheels_owned_at + travel(1.second) do + Wheel.create!(wheelable: car) + end + + assert_equal 1, car.reload.wheels_count + assert_equal 1, car.lock_version + assert_operator previously_updated_at, :<, car.updated_at + assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at + + previously_updated_at = car.updated_at + previously_wheels_owned_at = car.wheels_owned_at + travel(2.second) do + car.wheels.first.update(size: 42) + end + + assert_equal 1, car.reload.wheels_count + assert_equal 2, car.lock_version + assert_operator previously_updated_at, :<, car.updated_at + assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at + + previously_updated_at = car.updated_at + previously_wheels_owned_at = car.wheels_owned_at + travel(3.second) do + car.wheels.first.destroy! + end + + assert_equal 0, car.reload.wheels_count + assert_equal 3, car.lock_version + assert_operator previously_updated_at, :<, car.updated_at + assert_operator previously_wheels_owned_at, :<, car.wheels_owned_at + end + + def test_polymorphic_destroy_with_dependencies_and_lock_version + car = Car.create! + + assert_difference "car.wheels.count" do + car.wheels.create + end + assert_difference "car.wheels.count", -1 do + car.reload.destroy + end + assert_predicate car, :destroyed? + end + + def test_removing_has_and_belongs_to_many_associations_upon_destroy + p = RichPerson.create! first_name: "Jon" + p.treasures.create! + assert_not_empty p.treasures + p.destroy + assert_empty p.treasures + assert_empty RichPerson.connection.select_all("SELECT * FROM peoples_treasures WHERE rich_person_id = 1") + 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 tests, 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_tests = 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 Person and PersonalLegacyThing + add_counter_column_to(Person, "personal_legacy_things_count") + PersonalLegacyThing.reset_column_information + + # Make sure that counter incrementing doesn't cause problems + p1 = Person.new(first_name: "fjord") + p1.save! + t = PersonalLegacyThing.new(person: p1) + t.save! + p1.reload + assert_equal 1, p1.personal_legacy_things_count + assert p1.destroy + assert_equal true, p1.frozen? + assert_raises(ActiveRecord::RecordNotFound) { Person.find(p1.id) } + assert_raises(ActiveRecord::RecordNotFound) { PersonalLegacyThing.find(t.id) } + ensure + remove_counter_column_from(Person, "personal_legacy_things_count") + PersonalLegacyThing.reset_column_information + end + + def test_destroy_existing_object_with_locking_column_value_null_in_the_database + ActiveRecord::Base.connection.execute("INSERT INTO lock_without_defaults(title) VALUES('title1')") + t1 = LockWithoutDefault.last + + assert_equal 0, t1.lock_version + assert_nil t1.lock_version_before_type_cast + + t1.destroy + + assert_predicate t1, :destroyed? + end + + def test_destroy_stale_object + t1 = LockWithoutDefault.create!(title: "title1") + stale_object = LockWithoutDefault.find(t1.id) + + t1.update!(title: "title2") + + assert_raises(ActiveRecord::StaleObjectError) do + stale_object.destroy! + end + + assert_not_predicate stale_object, :destroyed? + 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_tests = 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 + + def test_lock_does_not_raise_when_the_object_is_not_dirty + person = Person.find 1 + assert_nothing_raised do + person.lock! + end + end + + def test_lock_raises_when_the_record_is_dirty + person = Person.find 1 + person.first_name = "fooman" + assert_raises(RuntimeError) do + person.lock! + end + end + + def test_locking_in_after_save_callback + assert_nothing_raised do + frog = ::Frog.create(name: "Old Frog") + frog.name = "New Frog" + assert_not_deprecated do + frog.save! + 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 \$?\d FOR SHARE NOWAIT/) do + person.lock!("FOR SHARE NOWAIT") + end + end + end + end + + def test_no_locks_no_wait + first, second = duel { Person.find 1 } + assert first.end > second.end + end + + private + 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 diff --git a/activerecord/test/cases/log_subscriber_test.rb b/activerecord/test/cases/log_subscriber_test.rb new file mode 100644 index 0000000000..ae2597adc8 --- /dev/null +++ b/activerecord/test/cases/log_subscriber_test.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +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 + REGEXP_CLEAR = Regexp.escape(ActiveRecord::LogSubscriber::CLEAR) + REGEXP_BOLD = Regexp.escape(ActiveRecord::LogSubscriber::BOLD) + REGEXP_MAGENTA = Regexp.escape(ActiveRecord::LogSubscriber::MAGENTA) + REGEXP_CYAN = Regexp.escape(ActiveRecord::LogSubscriber::CYAN) + SQL_COLORINGS = { + SELECT: Regexp.escape(ActiveRecord::LogSubscriber::BLUE), + INSERT: Regexp.escape(ActiveRecord::LogSubscriber::GREEN), + UPDATE: Regexp.escape(ActiveRecord::LogSubscriber::YELLOW), + DELETE: Regexp.escape(ActiveRecord::LogSubscriber::RED), + LOCK: Regexp.escape(ActiveRecord::LogSubscriber::WHITE), + ROLLBACK: Regexp.escape(ActiveRecord::LogSubscriber::RED), + TRANSACTION: REGEXP_CYAN, + OTHER: REGEXP_MAGENTA + } + Event = Struct.new(:duration, :payload) + + class TestDebugLogSubscriber < ActiveRecord::LogSubscriber + attr_reader :debugs + + def initialize + @debugs = [] + super + end + + def debug(progname = nil, &block) + @debugs << progname + super + end + end + + fixtures :posts + + def setup + @old_logger = ActiveRecord::Base.logger + Developer.primary_key + ActiveRecord::Base.connection.materialize_transactions + 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 + logger = TestDebugLogSubscriber.new + assert_equal 0, logger.debugs.length + + logger.sql(Event.new(0.9, sql: "hi mom!")) + assert_equal 1, logger.debugs.length + + logger.sql(Event.new(0.9, sql: "hi mom!", name: "foo")) + assert_equal 2, logger.debugs.length + + logger.sql(Event.new(0.9, sql: "hi mom!", name: "SCHEMA")) + assert_equal 2, logger.debugs.length + end + + def test_sql_statements_are_not_squeezed + logger = TestDebugLogSubscriber.new + logger.sql(Event.new(0.9, sql: "ruby rails")) + assert_match(/ruby rails/, logger.debugs.first) + 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_basic_query_logging_coloration + logger = TestDebugLogSubscriber.new + logger.colorize_logging = true + SQL_COLORINGS.each do |verb, color_regex| + logger.sql(Event.new(0.9, sql: verb.to_s)) + assert_match(/#{REGEXP_BOLD}#{color_regex}#{verb}#{REGEXP_CLEAR}/i, logger.debugs.last) + end + end + + def test_basic_payload_name_logging_coloration_generic_sql + logger = TestDebugLogSubscriber.new + logger.colorize_logging = true + SQL_COLORINGS.each do |verb, _| + logger.sql(Event.new(0.9, sql: verb.to_s)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + + logger.sql(Event.new(0.9, sql: verb.to_s, name: "SQL")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA}SQL \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + end + end + + def test_basic_payload_name_logging_coloration_named_sql + logger = TestDebugLogSubscriber.new + logger.colorize_logging = true + SQL_COLORINGS.each do |verb, _| + logger.sql(Event.new(0.9, sql: verb.to_s, name: "Model Load")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Load \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + + logger.sql(Event.new(0.9, sql: verb.to_s, name: "Model Exists")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}Model Exists \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + + logger.sql(Event.new(0.9, sql: verb.to_s, name: "ANY SPECIFIC NAME")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_CYAN}ANY SPECIFIC NAME \(0\.9ms\)#{REGEXP_CLEAR}/i, logger.debugs.last) + end + end + + def test_query_logging_coloration_with_nested_select + logger = TestDebugLogSubscriber.new + logger.colorize_logging = true + SQL_COLORINGS.slice(:SELECT, :INSERT, :UPDATE, :DELETE).each do |verb, color_regex| + logger.sql(Event.new(0.9, sql: "#{verb} WHERE ID IN SELECT")) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}#{verb} WHERE ID IN SELECT#{REGEXP_CLEAR}/i, logger.debugs.last) + end + end + + def test_query_logging_coloration_with_multi_line_nested_select + logger = TestDebugLogSubscriber.new + logger.colorize_logging = true + SQL_COLORINGS.slice(:SELECT, :INSERT, :UPDATE, :DELETE).each do |verb, color_regex| + sql = <<-EOS + #{verb} + WHERE ID IN ( + SELECT ID FROM THINGS + ) + EOS + logger.sql(Event.new(0.9, sql: sql)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{color_regex}.*#{verb}.*#{REGEXP_CLEAR}/mi, logger.debugs.last) + end + end + + def test_query_logging_coloration_with_lock + logger = TestDebugLogSubscriber.new + logger.colorize_logging = true + sql = <<-EOS + SELECT * FROM + (SELECT * FROM mytable FOR UPDATE) ss + WHERE col1 = 5; + EOS + logger.sql(Event.new(0.9, sql: sql)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*FOR UPDATE.*#{REGEXP_CLEAR}/mi, logger.debugs.last) + + sql = <<-EOS + LOCK TABLE films IN SHARE MODE; + EOS + logger.sql(Event.new(0.9, sql: sql)) + assert_match(/#{REGEXP_BOLD}#{REGEXP_MAGENTA} \(0\.9ms\)#{REGEXP_CLEAR} #{REGEXP_BOLD}#{SQL_COLORINGS[:LOCK]}.*LOCK TABLE.*#{REGEXP_CLEAR}/mi, logger.debugs.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_vebose_query_logs + ActiveRecord::Base.verbose_query_logs = true + + logger = TestDebugLogSubscriber.new + logger.sql(Event.new(0, sql: "hi mom!")) + assert_equal 2, @logger.logged(:debug).size + assert_match(/↳/, @logger.logged(:debug).last) + ensure + ActiveRecord::Base.verbose_query_logs = false + end + + def test_verbose_query_with_ignored_callstack + ActiveRecord::Base.verbose_query_logs = true + + logger = TestDebugLogSubscriber.new + def logger.extract_query_source_location(*); nil; end + + logger.sql(Event.new(0, sql: "hi mom!")) + assert_equal 1, @logger.logged(:debug).size + assert_no_match(/↳/, @logger.logged(:debug).last) + ensure + ActiveRecord::Base.verbose_query_logs = false + end + + def test_verbose_query_logs_disabled_by_default + logger = TestDebugLogSubscriber.new + logger.sql(Event.new(0, sql: "hi mom!")) + assert_no_match(/↳/, @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 + + if ActiveRecord::Base.connection.prepared_statements + 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_binary_data_hash + Binary.create(data: { a: 1 }) + wait + assert_match(/<7 bytes of 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..7777508349 --- /dev/null +++ b/activerecord/test/cases/migration/change_schema_test.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +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::NotNullViolation) 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?(: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, connection.lookup_cast_type_from_column(two).deserialize(two.default) + assert_equal false, connection.lookup_cast_type_from_column(three).deserialize(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_predicate 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_predicate array_column, :array? + end + end + + def test_create_table_with_bigint + connection.create_table :testings do |t| + t.bigint :eight_int + end + columns = connection.columns(:testings) + eight = columns.detect { |c| c.name == "eight_int" } + + if current_adapter?(:OracleAdapter) + assert_equal "NUMBER(19)", eight.sql_type + elsif current_adapter?(:SQLite3Adapter) + assert_equal "bigint", eight.sql_type + else + assert_equal :integer, eight.type + assert_equal 8, eight.limit + end + ensure + connection.drop_table :testings + 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?(: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_raises_when_defining_existing_column + error = assert_raise(ArgumentError) do + connection.create_table :testings do |t| + t.column :testing_column, :string + t.column :testing_column, :integer + end + end + + assert_equal "you can't define an already defined column 'testing_column'.", 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_not created_at_column.null + assert_not 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: true + 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::NotNullViolation) 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 + + quoted_id = connection.quote_column_name("id") + quoted_foo = connection.quote_column_name("foo") + quoted_bar = connection.quote_column_name("bar") + connection.execute("insert into testings (#{quoted_id}, #{quoted_foo}) values (1, 'hello')") + assert_nothing_raised do + connection.add_column :testings, :bar, :string, null: false, default: "default" + end + + assert_raises(ActiveRecord::NotNullViolation) do + connection.execute("insert into testings (#{quoted_id}, #{quoted_foo}, #{quoted_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 + + column = connection.columns(:testings).find { |c| c.name == "foo" } + + assert_equal :datetime, column.type + + if current_adapter?(:PostgreSQLAdapter) + assert_equal "timestamp without time zone", column.sql_type + elsif current_adapter?(:Mysql2Adapter) + assert_equal "timestamp", column.sql_type + elsif current_adapter?(:OracleAdapter) + assert_equal "TIMESTAMP(6)", column.sql_type + else + assert_equal connection.type_to_sql("datetime"), column.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::Current) 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_not (t.column_exists?(:bar)) + end + end + + def test_drop_table_if_exists + connection.create_table(:testings) + assert connection.table_exists?(:testings) + connection.drop_table(:testings, if_exists: true) + assert_not connection.table_exists?(:testings) + end + + def test_drop_table_if_exists_nothing_raised + assert_nothing_raised { connection.drop_table(:nonexistent, if_exists: true) } + 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 + + if ActiveRecord::Base.connection.supports_foreign_keys? + class ChangeSchemaWithDependentObjectsTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :trains + @connection.create_table(:wagons) { |t| t.references :train } + @connection.add_foreign_key :wagons, :trains + end + + teardown do + [:wagons, :trains].each do |table| + @connection.drop_table table, if_exists: true + end + end + + def test_create_table_with_force_cascade_drops_dependent_objects + skip "MySQL > 5.5 does not drop dependent objects with DROP TABLE CASCADE" if current_adapter?(:Mysql2Adapter) + # can't re-create table referenced by foreign key + assert_raises(ActiveRecord::StatementInvalid) do + @connection.create_table :trains, force: true + end + + # can recreate referenced table with force: :cascade + @connection.create_table :trains, force: :cascade + assert_equal [], @connection.foreign_keys(:wagons) + end + 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..c108d372d1 --- /dev/null +++ b/activerecord/test/cases/migration/change_table_test.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require "cases/migration/helper" + +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 ActiveRecord::Base.connection.update_table_definition(: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, null: true] + t.timestamps null: true + end + end + + def test_remove_timestamps_creates_updated_at_and_created_at + with_change_table do |t| + @connection.expect :remove_timestamps, nil, [:delete_me, { null: true }] + t.remove_timestamps(null: true) + end + end + + def test_primary_key_creates_primary_key_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :id, :primary_key, primary_key: true, first: true] + t.primary_key :id, first: true + 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_bigint_creates_bigint_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :foo, :bigint, {}] + @connection.expect :add_column, nil, [:delete_me, :bar, :bigint, {}] + t.bigint :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 + + if current_adapter?(:PostgreSQLAdapter) + def test_json_creates_json_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :foo, :json, {}] + @connection.expect :add_column, nil, [:delete_me, :bar, :json, {}] + t.json :foo, :bar + end + end + + def test_xml_creates_xml_column + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :foo, :xml, {}] + @connection.expect :add_column, nil, [:delete_me, :bar, :xml, {}] + t.xml :foo, :bar + end + 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_column_creates_column_with_index + with_change_table do |t| + @connection.expect :add_column, nil, [:delete_me, :bar, :integer, {}] + @connection.expect :add_index, nil, [:delete_me, :bar, {}] + t.column :bar, :integer, index: true + 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 + + def test_table_name_set + with_change_table do |t| + assert_equal :delete_me, t.name + 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..3022121f4c --- /dev/null +++ b/activerecord/test/cases/migration/column_attributes_test.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "cases/migration/helper" + +module ActiveRecord + class Migration + class ColumnAttributesTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_tests = 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?(:Mysql2Adapter) + add_column :test_models, :description, :string, limit: nil + TestModel.reset_column_information + assert_nil TestModel.columns_hash["description"].limit + end + + if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + def test_unabstracted_database_dependent_types + add_column :test_models, :intelligence_quotient, :smallint + TestModel.reset_column_information + assert_match(/smallint/, 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)" + 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! + assert_equal correct_value, row.wealth + + # Reset to old state + TestModel.delete_all + + # Now use the Rails insertion + TestModel.create wealth: BigDecimal("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 + + # Test SQLite3 adapter specifically for decimal types with precision and scale + # attributes, since these need to be maintained in schema but aren't actually + # used in SQLite3 itself + if current_adapter?(:SQLite3Adapter) + def test_change_column_with_new_precision_and_scale + connection.add_column "test_models", "wealth", :decimal, precision: 9, scale: 7 + + connection.change_column "test_models", "wealth", :decimal, precision: 12, scale: 8 + TestModel.reset_column_information + + wealth_column = TestModel.columns_hash["wealth"] + assert_equal 12, wealth_column.precision + assert_equal 8, wealth_column.scale + end + + 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("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("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_kind_of Integer, bob.age + assert_equal Time, bob.birthday.class + assert_equal Date, bob.favorite_day.class + assert_instance_of TrueClass, bob.male? + assert_kind_of BigDecimal, bob.wealth + end + end + + if current_adapter?(: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, :text, 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..1c62a68cf9 --- /dev/null +++ b/activerecord/test/cases/migration/column_positioning_test.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class Migration + class ColumnPositioningTest < ActiveRecord::TestCase + attr_reader :connection + 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?(:Mysql2Adapter) + def test_column_positioning + assert_equal %w(first second third), conn.columns(:testings).map(&: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(&: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(&: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(&: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(&:name) + + conn.change_column :testings, :second, :integer, after: :third + assert_equal %w(first third second), conn.columns(:testings).map(&:name) + end + + def test_add_reference_with_positioning_first + conn.add_reference :testings, :new, polymorphic: true, first: true + assert_equal %w(new_id new_type first second third), conn.columns(:testings).map(&:name) + end + + def test_add_reference_with_positioning_after + conn.add_reference :testings, :new, polymorphic: true, after: :first + assert_equal %w(first new_id new_type second third), conn.columns(:testings).map(&: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..cedd9c44e3 --- /dev/null +++ b/activerecord/test/cases/migration/columns_test.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require "cases/migration/helper" + +module ActiveRecord + class Migration + class ColumnsTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_tests = 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_includes TestModel.column_names, "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_includes TestModel.column_names, "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_includes TestModel.column_names, "annual_salary" + default_after = connection.columns("test_models").find { |c| c.name == "annual_salary" }.default + assert_equal "70000", default_after + end + + if current_adapter?(:Mysql2Adapter) + def test_mysql_rename_column_preserves_auto_increment + rename_column "test_models", "id", "id_test" + assert_predicate connection.columns("test_models").find { |c| c.name == "id_test" }, :auto_increment? + TestModel.reset_column_information + ensure + rename_column "test_models", "id_test", "id" + 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_includes TestModel.column_names, "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 + # MariaDB starting with 10.2.8 + # Dropping a column that is part of a multi-column UNIQUE constraint is not permitted. + skip if current_adapter?(:Mysql2Adapter) && connection.mariadb? && connection.version >= "10.2.8" + + 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" && c.type == :integer } + assert new_columns.find { |c| c.name == "age" && c.type == :string } + + old_columns = connection.columns(TestModel.table_name) + assert old_columns.find { |c| + default = connection.lookup_cast_type_from_column(c).deserialize(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 = connection.lookup_cast_type_from_column(c).deserialize(c.default) + c.name == "approved" && c.type == :boolean && default == true + } + assert new_columns.find { |c| + default = connection.lookup_cast_type_from_column(c).deserialize(c.default) + c.name == "approved" && c.type == :boolean && 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_predicate TestModel.new, :contributor? + + change_column "test_models", "contributor", :boolean, default: nil + TestModel.reset_column_information + assert_not_predicate TestModel.new, :contributor? + assert_nil TestModel.new.contributor + end + + def test_change_column_to_drop_default_with_null_false + add_column "test_models", "contributor", :boolean, default: true, null: false + assert_predicate TestModel.new, :contributor? + + change_column "test_models", "contributor", :boolean, default: nil, null: false + TestModel.reset_column_information + assert_not_predicate 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_predicate TestModel.new, :administrator? + + change_column "test_models", "administrator", :boolean, default: false + TestModel.reset_column_information + assert_not_predicate 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_change_column_default_with_from_and_to + add_column "test_models", "first_name", :string + connection.change_column_default "test_models", "first_name", from: nil, to: "Tester" + + assert_equal "Tester", 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..01f8628fc5 --- /dev/null +++ b/activerecord/test/cases/migration/command_recorder_test.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +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_respond_to recorder, :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_respond_to recorder, :create_table + recorder.send(:create_table, :horses) + assert_equal [[:create_table, [:horses], nil]], recorder.commands + end + + def test_unknown_commands_delegate + recorder = Struct.new(:foo) + recorder = CommandRecorder.new(recorder.new("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_default_with_from_and_to + change = @recorder.inverse_of :change_column_default, [:table, :column, from: "old_value", to: "new_value"] + assert_equal [:change_column_default, [:table, :column, from: "new_value", to: "old_value"]], change + end + + def test_invert_change_column_default_with_from_and_to_with_boolean + change = @recorder.inverse_of :change_column_default, [:table, :column, from: true, to: false] + assert_equal [:change_column_default, [:table, :column, from: false, to: true]], change + 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_algorithm_option + remove = @recorder.inverse_of :add_index, [:table, :one, algorithm: :concurrently] + assert_equal [:remove_index, [:table, { column: :one, algorithm: :concurrently }]], remove + end + + def test_invert_remove_index + add = @recorder.inverse_of :remove_index, [:table, :one] + assert_equal [:add_index, [:table, :one]], add + end + + def test_invert_remove_index_with_column + 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, { null: true }] + assert_equal [:add_timestamps, [:table, { null: true }], 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_reference_with_index_and_foreign_key + add = @recorder.inverse_of :remove_reference, [:table, :taggable, { index: true, foreign_key: true }] + assert_equal [:add_reference, [:table, :taggable, { index: true, foreign_key: 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_remove_foreign_key + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people] + assert_equal [:add_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_remove_foreign_key_with_column + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, column: "owner_id"] + assert_equal [:add_foreign_key, [:dogs, :people, 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_invert_remove_foreign_key_with_column_and_name + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, column: "owner_id", name: "fk"] + assert_equal [:add_foreign_key, [:dogs, :people, column: "owner_id", name: "fk"]], enable + end + + def test_invert_remove_foreign_key_with_primary_key + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, primary_key: "person_id"] + assert_equal [:add_foreign_key, [:dogs, :people, primary_key: "person_id"]], enable + end + + def test_invert_remove_foreign_key_with_primary_key_and_to_table_in_options + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people, primary_key: "uuid"] + assert_equal [:add_foreign_key, [:dogs, :people, primary_key: "uuid"]], enable + end + + def test_invert_remove_foreign_key_with_on_delete_on_update + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, :people, on_delete: :nullify, on_update: :cascade] + assert_equal [:add_foreign_key, [:dogs, :people, on_delete: :nullify, on_update: :cascade]], enable + end + + def test_invert_remove_foreign_key_with_to_table_in_options + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people] + assert_equal [:add_foreign_key, [:dogs, :people]], enable + + enable = @recorder.inverse_of :remove_foreign_key, [:dogs, to_table: :people, column: :owner_id] + assert_equal [:add_foreign_key, [:dogs, :people, column: :owner_id]], enable + end + + def test_invert_remove_foreign_key_is_irreversible_without_to_table + 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 + + assert_raises ActiveRecord::IrreversibleMigration do + @recorder.inverse_of :remove_foreign_key, [:dogs] + end + end + + def test_invert_transaction_with_irreversible_inside_is_irreversible + assert_raises(ActiveRecord::IrreversibleMigration) do + @recorder.revert do + @recorder.transaction do + @recorder.execute "some sql" + end + end + end + end + end + end +end diff --git a/activerecord/test/cases/migration/compatibility_test.rb b/activerecord/test/cases/migration/compatibility_test.rb new file mode 100644 index 0000000000..017ee7951e --- /dev/null +++ b/activerecord/test/cases/migration/compatibility_test.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +module ActiveRecord + class Migration + class CompatibilityTest < ActiveRecord::TestCase + attr_reader :connection + self.use_transactional_tests = false + + def setup + super + @connection = ActiveRecord::Base.connection + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + + connection.create_table :testings do |t| + t.column :foo, :string, limit: 5 + t.column :bar, :string, limit: 100 + end + end + + teardown do + connection.drop_table :testings rescue nil + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::SchemaMigration.delete_all rescue nil + end + + def test_migration_doesnt_remove_named_index + connection.add_index :testings, :foo, name: "custom_index_name" + + migration = Class.new(ActiveRecord::Migration[4.2]) { + def version; 101 end + def migrate(x) + remove_index :testings, :foo + end + }.new + + assert connection.index_exists?(:testings, :foo, name: "custom_index_name") + assert_raise(StandardError) { ActiveRecord::Migrator.new(:up, [migration]).migrate } + assert connection.index_exists?(:testings, :foo, name: "custom_index_name") + end + + def test_migration_does_remove_unnamed_index + connection.add_index :testings, :bar + + migration = Class.new(ActiveRecord::Migration[4.2]) { + def version; 101 end + def migrate(x) + remove_index :testings, :bar + end + }.new + + assert connection.index_exists?(:testings, :bar) + ActiveRecord::Migrator.new(:up, [migration]).migrate + assert_not connection.index_exists?(:testings, :bar) + end + + def test_references_does_not_add_index_by_default + migration = Class.new(ActiveRecord::Migration[4.2]) { + def migrate(x) + create_table :more_testings do |t| + t.references :foo + t.belongs_to :bar, index: false + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert_not connection.index_exists?(:more_testings, :foo_id) + assert_not connection.index_exists?(:more_testings, :bar_id) + ensure + connection.drop_table :more_testings rescue nil + end + + def test_timestamps_have_null_constraints_if_not_present_in_migration_of_create_table + migration = Class.new(ActiveRecord::Migration[4.2]) { + def migrate(x) + create_table :more_testings do |t| + t.timestamps + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.columns(:more_testings).find { |c| c.name == "created_at" }.null + assert connection.columns(:more_testings).find { |c| c.name == "updated_at" }.null + ensure + connection.drop_table :more_testings rescue nil + end + + def test_timestamps_have_null_constraints_if_not_present_in_migration_of_change_table + migration = Class.new(ActiveRecord::Migration[4.2]) { + def migrate(x) + change_table :testings do |t| + t.timestamps + end + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.columns(:testings).find { |c| c.name == "created_at" }.null + assert connection.columns(:testings).find { |c| c.name == "updated_at" }.null + end + + def test_timestamps_have_null_constraints_if_not_present_in_migration_for_adding_timestamps_to_existing_table + migration = Class.new(ActiveRecord::Migration[4.2]) { + def migrate(x) + add_timestamps :testings + end + }.new + + ActiveRecord::Migrator.new(:up, [migration]).migrate + + assert connection.columns(:testings).find { |c| c.name == "created_at" }.null + assert connection.columns(:testings).find { |c| c.name == "updated_at" }.null + end + + def test_legacy_migrations_raises_exception_when_inherited + e = assert_raises(StandardError) do + class_eval("class LegacyMigration < ActiveRecord::Migration; end") + end + assert_match(/LegacyMigration < ActiveRecord::Migration\[4\.2\]/, e.message) + end + + def test_legacy_migrations_not_raise_exception_on_reverting_transaction + migration = Class.new(ActiveRecord::Migration[5.2]) { + def change + transaction do + execute "select 1" + end + end + }.new + + assert_nothing_raised do + migration.migrate(:down) + end + end + + if current_adapter?(:PostgreSQLAdapter) + class Testing < ActiveRecord::Base + end + + def test_legacy_change_column_with_null_executes_update + migration = Class.new(ActiveRecord::Migration[5.1]) { + def migrate(x) + change_column :testings, :foo, :string, limit: 10, null: false, default: "foobar" + end + }.new + + Testing.create! + ActiveRecord::Migrator.new(:up, [migration]).migrate + assert_equal ["foobar"], Testing.all.map(&:foo) + ensure + ActiveRecord::Base.clear_cache! + end + end + end + end +end + +module LegacyPrimaryKeyTestCases + include SchemaDumpingHelper + + class LegacyPrimaryKey < ActiveRecord::Base + end + + def setup + @migration = nil + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.verbose = false + end + + def teardown + @migration.migrate(:down) if @migration + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::SchemaMigration.delete_all rescue nil + LegacyPrimaryKey.reset_column_information + end + + def test_legacy_primary_key_should_be_auto_incremented + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys do |t| + t.references :legacy_ref + end + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + + legacy_ref = LegacyPrimaryKey.columns_hash["legacy_ref_id"] + assert_not_predicate legacy_ref, :bigint? + + record1 = LegacyPrimaryKey.create! + assert_not_nil record1.id + + record1.destroy + + record2 = LegacyPrimaryKey.create! + assert_not_nil record2.id + assert_operator record2.id, :>, record1.id + end + + def test_legacy_integer_primary_key_should_not_be_auto_incremented + skip if current_adapter?(:SQLite3Adapter) + + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: :integer do |t| + end + end + }.new + + @migration.migrate(:up) + + assert_raises(ActiveRecord::NotNullViolation) do + LegacyPrimaryKey.create! + end + + schema = dump_table_schema "legacy_primary_keys" + assert_match %r{create_table "legacy_primary_keys", id: :integer, default: nil}, schema + end + + def test_legacy_primary_key_in_create_table_should_be_integer + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: false do |t| + t.primary_key :id + end + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + end + + def test_legacy_primary_key_in_change_table_should_be_integer + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: false do |t| + t.integer :dummy + end + change_table :legacy_primary_keys do |t| + t.primary_key :id + end + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + end + + def test_add_column_with_legacy_primary_key_should_be_integer + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: false do |t| + t.integer :dummy + end + add_column :legacy_primary_keys, :id, :primary_key + end + }.new + + @migration.migrate(:up) + + assert_legacy_primary_key + end + + def test_legacy_join_table_foreign_keys_should_be_integer + @migration = Class.new(migration_class) { + def change + create_join_table :apples, :bananas do |t| + end + end + }.new + + @migration.migrate(:up) + + schema = dump_table_schema "apples_bananas" + assert_match %r{integer "apple_id", null: false}, schema + assert_match %r{integer "banana_id", null: false}, schema + end + + def test_legacy_join_table_column_options_should_be_overwritten + @migration = Class.new(migration_class) { + def change + create_join_table :apples, :bananas, column_options: { type: :bigint } do |t| + end + end + }.new + + @migration.migrate(:up) + + schema = dump_table_schema "apples_bananas" + assert_match %r{bigint "apple_id", null: false}, schema + assert_match %r{bigint "banana_id", null: false}, schema + end + + if current_adapter?(:Mysql2Adapter) + def test_legacy_bigint_primary_key_should_be_auto_incremented + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: :bigint + end + }.new + + @migration.migrate(:up) + + legacy_pk = LegacyPrimaryKey.columns_hash["id"] + assert_predicate legacy_pk, :bigint? + assert_predicate legacy_pk, :auto_increment? + + schema = dump_table_schema "legacy_primary_keys" + assert_match %r{create_table "legacy_primary_keys", (?!id: :bigint, default: nil)}, schema + end + else + def test_legacy_bigint_primary_key_should_not_be_auto_incremented + @migration = Class.new(migration_class) { + def change + create_table :legacy_primary_keys, id: :bigint do |t| + end + end + }.new + + @migration.migrate(:up) + + assert_raises(ActiveRecord::NotNullViolation) do + LegacyPrimaryKey.create! + end + + schema = dump_table_schema "legacy_primary_keys" + assert_match %r{create_table "legacy_primary_keys", id: :bigint, default: nil}, schema + end + end + + private + def assert_legacy_primary_key + assert_equal "id", LegacyPrimaryKey.primary_key + + legacy_pk = LegacyPrimaryKey.columns_hash["id"] + + assert_equal :integer, legacy_pk.type + assert_not_predicate legacy_pk, :bigint? + assert_not legacy_pk.null + + if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter) + schema = dump_table_schema "legacy_primary_keys" + assert_match %r{create_table "legacy_primary_keys", id: :(?:integer|serial), (?!default: nil)}, schema + end + end +end + +module LegacyPrimaryKeyTest + class V5_0 < ActiveRecord::TestCase + include LegacyPrimaryKeyTestCases + + self.use_transactional_tests = false + + private + def migration_class + ActiveRecord::Migration[5.0] + end + end + + class V4_2 < ActiveRecord::TestCase + include LegacyPrimaryKeyTestCases + + self.use_transactional_tests = false + + private + def migration_class + ActiveRecord::Migration[4.2] + 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..e0cbb29dcf --- /dev/null +++ b/activerecord/test/cases/migration/create_join_table_test.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +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_exists: true + 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_predicate 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_create_join_table_respects_reference_key_type + connection.create_join_table :artists, :musics do |t| + t.references :video + end + + artist_id, music_id, video_id = connection.columns(:artists_musics).sort_by(&:name) + + assert_equal video_id.sql_type, artist_id.sql_type + assert_equal video_id.sql_type, music_id.sql_type + end + + def test_drop_join_table + connection.create_join_table :artists, :musics + connection.drop_join_table :artists, :musics + + assert_not connection.table_exists?("artists_musics") + end + + def test_drop_join_table_with_strings + connection.create_join_table :artists, :musics + connection.drop_join_table "artists", "musics" + + assert_not connection.table_exists?("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_not connection.table_exists?("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_not connection.table_exists?("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_not connection.table_exists?("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_not connection.table_exists?("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 connection.table_exists?("audio_artists_musics") + + connection.drop_join_table "audio_artists", "audio_musics" + assert_not connection.table_exists?("audio_artists_musics"), "Should have dropped join table, but didn't" + end + end + + if current_adapter?(:PostgreSQLAdapter) + def test_create_join_table_with_uuid + connection.create_join_table :artists, :musics, column_options: { type: :uuid } + assert_equal [:uuid, :uuid], connection.columns(:artists_musics).map(&:type) + end + end + + private + + def with_table_cleanup + tables_before = connection.data_sources + + yield + ensure + tables_after = connection.data_sources - tables_before + + tables_after.each do |table| + connection.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..bb233fbf74 --- /dev/null +++ b/activerecord/test/cases/migration/foreign_key_test.rb @@ -0,0 +1,497 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +if ActiveRecord::Base.connection.supports_foreign_keys_in_create? + module ActiveRecord + class Migration + class ForeignKeyInCreateTest < ActiveRecord::TestCase + def test_foreign_keys + foreign_keys = ActiveRecord::Base.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 unless current_adapter?(:SQLite3Adapter) + end + end + + class ForeignKeyChangeColumnTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class Rocket < ActiveRecord::Base + has_many :astronauts + end + + class Astronaut < ActiveRecord::Base + belongs_to :rocket + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "rockets", force: true do |t| + t.string :name + end + + @connection.create_table "astronauts", force: true do |t| + t.string :name + t.references :rocket, foreign_key: true + end + Rocket.reset_column_information + Astronaut.reset_column_information + end + + teardown do + @connection.drop_table "astronauts", if_exists: true + @connection.drop_table "rockets", if_exists: true + Rocket.reset_column_information + Astronaut.reset_column_information + end + + def test_change_column_of_parent_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.change_column_null :rockets, :name, false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + end + + def test_rename_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.rename_column :astronauts, :name, :astronaut_name + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + end + + def test_rename_reference_column_of_child_table + rocket = Rocket.create!(name: "myrocket") + rocket.astronauts << Astronaut.create! + + @connection.rename_column :astronauts, :rocket_id, :new_rocket_id + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_equal "myrocket", Rocket.first.name + assert_equal "astronauts", fk.from_table + assert_equal "rockets", fk.to_table + assert_equal "new_rocket_id", fk.options[:column] + end + end + end + end +end + +if ActiveRecord::Base.connection.supports_foreign_keys? + module ActiveRecord + class Migration + class ForeignKeyTest < ActiveRecord::TestCase + include SchemaDumpingHelper + include ActiveSupport::Testing::Stream + + class Rocket < ActiveRecord::Base + end + + class Astronaut < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table "rockets", force: true do |t| + t.string :name + end + + @connection.create_table "astronauts", force: true do |t| + t.string :name + t.references :rocket + end + end + + teardown do + @connection.drop_table "astronauts", if_exists: true + @connection.drop_table "rockets", if_exists: true + 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_equal("fk_rails_78146ddd2e", 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_equal("fk_rails_78146ddd2e", fk.name) + end + + def test_add_foreign_key_with_non_standard_primary_key + @connection.create_table :space_shuttles, id: false, force: true do |t| + t.bigint :pk, primary_key: true + end + + @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 + ensure + @connection.remove_foreign_key :astronauts, name: "custom_pk" + @connection.drop_table :space_shuttles + 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?(:Mysql2Adapter) + # ON DELETE RESTRICT is the default on MySQL + assert_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_foreign_key_exists + @connection.add_foreign_key :astronauts, :rockets + + assert @connection.foreign_key_exists?(:astronauts, :rockets) + assert_not @connection.foreign_key_exists?(:astronauts, :stars) + end + + def test_foreign_key_exists_by_column + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id" + + assert @connection.foreign_key_exists?(:astronauts, column: "rocket_id") + assert_not @connection.foreign_key_exists?(:astronauts, column: "star_id") + end + + def test_foreign_key_exists_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk" + + assert @connection.foreign_key_exists?(:astronauts, name: "fancy_named_fk") + assert_not @connection.foreign_key_exists?(:astronauts, name: "other_fancy_named_fk") + 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_symbol_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 + + if ActiveRecord::Base.connection.supports_validate_constraints? + def test_add_invalid_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_not_predicate fk, :validated? + end + + def test_validate_foreign_key_infers_column + @connection.add_foreign_key :astronauts, :rockets, validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, :rockets + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_key_by_column + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, column: "rocket_id" + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_key_by_symbol_column + @connection.add_foreign_key :astronauts, :rockets, column: :rocket_id, validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, column: :rocket_id + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_key_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false + assert_not_predicate @connection.foreign_keys("astronauts").first, :validated? + + @connection.validate_foreign_key :astronauts, name: "fancy_named_fk" + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + + def test_validate_foreign_non_existing_foreign_key_raises + assert_raises ArgumentError do + @connection.validate_foreign_key :astronauts, :rockets + end + end + + def test_validate_constraint_by_name + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", name: "fancy_named_fk", validate: false + + @connection.validate_constraint :astronauts, "fancy_named_fk" + assert_predicate @connection.foreign_keys("astronauts").first, :validated? + end + else + # Foreign key should still be created, but should not be invalid + def test_add_invalid_foreign_key + @connection.add_foreign_key :astronauts, :rockets, column: "rocket_id", validate: false + + foreign_keys = @connection.foreign_keys("astronauts") + assert_equal 1, foreign_keys.size + + fk = foreign_keys.first + assert_predicate fk, :validated? + 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_with_custom_fk_ignore_pattern + original_pattern = ActiveRecord::SchemaDumper.fk_ignore_pattern + ActiveRecord::SchemaDumper.fk_ignore_pattern = /^ignored_/ + @connection.add_foreign_key :astronauts, :rockets, name: :ignored_fk_astronauts_rockets + + output = dump_table_schema "astronauts" + assert_match %r{\s+add_foreign_key "astronauts", "rockets"$}, output + + ActiveRecord::SchemaDumper.fk_ignore_pattern = original_pattern + 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::Current + def change + create_table("cities") { |t| } + + create_table("houses") do |t| + t.references :city + end + add_foreign_key :houses, :cities, column: "city_id" + + # remove and re-add to test that schema is updated and not accidentally cached + remove_foreign_key :houses, :cities + add_foreign_key :houses, :cities, column: "city_id", on_delete: :cascade + 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 + + def test_foreign_key_constraint_is_not_cached_incorrectly + migration = CreateCitiesAndHousesMigration.new + silence_stream($stdout) { migration.migrate(:up) } + output = dump_table_schema "houses" + assert_match %r{\s+add_foreign_key "houses",.+on_delete: :cascade$}, output + ensure + silence_stream($stdout) { migration.migrate(:down) } + end + + class CreateSchoolsAndClassesMigration < ActiveRecord::Migration::Current + def change + create_table(:schools) + + create_table(:classes) do |t| + t.references :school + end + add_foreign_key :classes, :schools + end + end + + def test_add_foreign_key_with_prefix + ActiveRecord::Base.table_name_prefix = "p_" + migration = CreateSchoolsAndClassesMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("p_classes").size + ensure + silence_stream($stdout) { migration.migrate(:down) } + ActiveRecord::Base.table_name_prefix = nil + end + + def test_add_foreign_key_with_suffix + ActiveRecord::Base.table_name_suffix = "_s" + migration = CreateSchoolsAndClassesMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("classes_s").size + ensure + silence_stream($stdout) { migration.migrate(:down) } + ActiveRecord::Base.table_name_suffix = nil + 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 + + unless current_adapter?(:SQLite3Adapter) + def test_foreign_keys_should_raise_not_implemented + assert_raises NotImplementedError do + @connection.foreign_keys("clubs") + end + 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..c056199140 --- /dev/null +++ b/activerecord/test/cases/migration/helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class Migration + class << self; attr_accessor :message_count; end + self.message_count = 0 + + 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 null: true + end + + TestModel.reset_column_information + end + + def teardown + super + TestModel.reset_table_name + TestModel.reset_sequence_name + connection.drop_table :test_models, if_exists: true + 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..f8fecc83cd --- /dev/null +++ b/activerecord/test/cases/migration/index_test.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +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") + + assert_not connection.index_name_exists?(table_name, "old_idx") + assert connection.index_name_exists?(table_name, "new_idx") + end + + def test_rename_index_too_long + too_long_index_name = good_index_name + "x" + # keep the names short to make Oracle and similar behave + connection.add_index(table_name, [:foo], name: "old_idx") + e = assert_raises(ArgumentError) { + connection.rename_index(table_name, "old_idx", too_long_index_name) + } + assert_match(/too long; the limit is #{connection.allowed_index_name_length} characters/, e.message) + + assert connection.index_name_exists?(table_name, "old_idx") + 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) + 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) + 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) + 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_not 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_index_exists_with_custom_name_checks_columns + connection.add_index :testings, [:foo, :bar], name: "my_index" + assert connection.index_exists?(:testings, [:foo, :bar], name: "my_index") + assert_not connection.index_exists?(:testings, [:foo], name: "my_index") + 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) + assert connection.index_exists?(:testings, :foo, name: "custom_index_name") + assert_not connection.index_exists?(:testings, :foo, name: "other_index_name") + end + + def test_remove_named_index + connection.add_index :testings, :foo, name: "index_testings_on_custom_index_name" + + assert connection.index_exists?(:testings, :foo) + + assert_raise(ArgumentError) { connection.remove_index(:testings, "custom_index_name") } + + connection.remove_index :testings, :foo + assert_not connection.index_exists?(:testings, :foo) + 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, :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_not 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..28f4cc124b --- /dev/null +++ b/activerecord/test/cases/migration/logger_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class Migration + class LoggerTest < ActiveRecord::TestCase + # MySQL can't roll back ddl changes + self.use_transactional_tests = 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..119bfd372a --- /dev/null +++ b/activerecord/test/cases/migration/pending_migrations_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class Migration + if current_adapter?(:SQLite3Adapter) && !in_memory_db? + class PendingMigrationsTest < ActiveRecord::TestCase + setup do + file = ActiveRecord::Base.connection.raw_connection.filename + @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:", migrations_paths: MIGRATIONS_ROOT + "/valid" + source_db = SQLite3::Database.new file + dest_db = ActiveRecord::Base.connection.raw_connection + backup = SQLite3::Backup.new(dest_db, "main", source_db, "main") + backup.step(-1) + backup.finish + end + + teardown do + @conn.release_connection if @conn + ActiveRecord::Base.establish_connection :arunit + end + + def test_errors_if_pending + ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true + + assert_raises ActiveRecord::PendingMigrationError do + CheckPending.new(Proc.new { }).call({}) + end + end + + def test_checks_if_supported + ActiveRecord::SchemaMigration.create_table + migrator = Base.connection.migration_context + capture(:stdout) { migrator.migrate } + + assert_nil CheckPending.new(Proc.new { }).call({}) + end + end + end + end +end diff --git a/activerecord/test/cases/migration/references_foreign_key_test.rb b/activerecord/test/cases/migration/references_foreign_key_test.rb new file mode 100644 index 0000000000..620e9ab6ca --- /dev/null +++ b/activerecord/test/cases/migration/references_foreign_key_test.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require "cases/helper" + +if ActiveRecord::Base.connection.supports_foreign_keys_in_create? + module ActiveRecord + class Migration + class ReferencesForeignKeyInCreateTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:testing_parents, force: true) + end + + teardown do + @connection.drop_table "testings", if_exists: true + @connection.drop_table "testing_parents", if_exists: true + end + + test "foreign keys can be created with the table" do + @connection.create_table :testings do |t| + t.references :testing_parent, foreign_key: true + end + + fk = @connection.foreign_keys("testings").first + assert_equal "testings", fk.from_table + assert_equal "testing_parents", fk.to_table + end + + test "no foreign key is created by default" do + @connection.create_table :testings do |t| + t.references :testing_parent + end + + assert_equal [], @connection.foreign_keys("testings") + end + + test "foreign keys can be created in one query when index is not added" do + assert_queries(1) do + @connection.create_table :testings do |t| + t.references :testing_parent, foreign_key: true, index: false + end + end + end + + test "options hash can be passed" do + @connection.change_table :testing_parents do |t| + t.references :other, index: { unique: true } + end + @connection.create_table :testings do |t| + t.references :testing_parent, foreign_key: { primary_key: :other_id } + end + + fk = @connection.foreign_keys("testings").find { |k| k.to_table == "testing_parents" } + assert_equal "other_id", fk.primary_key + end + + test "to_table option can be passed" do + @connection.create_table :testings do |t| + t.references :parent, foreign_key: { to_table: :testing_parents } + end + fks = @connection.foreign_keys("testings") + assert_equal([["testings", "testing_parents", "parent_id"]], + fks.map { |fk| [fk.from_table, fk.to_table, fk.column] }) + end + end + end + end +end + +if ActiveRecord::Base.connection.supports_foreign_keys? + module ActiveRecord + class Migration + class ReferencesForeignKeyTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:testing_parents, force: true) + end + + teardown do + @connection.drop_table "testings", if_exists: true + @connection.drop_table "testing_parents", if_exists: true + end + + test "foreign keys cannot be added to polymorphic relations when creating the table" do + @connection.create_table :testings do |t| + assert_raises(ArgumentError) do + t.references :testing_parent, polymorphic: true, foreign_key: true + end + end + end + + test "foreign keys can be created while changing the table" do + @connection.create_table :testings + @connection.change_table :testings do |t| + t.references :testing_parent, foreign_key: true + end + + fk = @connection.foreign_keys("testings").first + assert_equal "testings", fk.from_table + assert_equal "testing_parents", fk.to_table + end + + test "foreign keys are not added by default when changing the table" do + @connection.create_table :testings + @connection.change_table :testings do |t| + t.references :testing_parent + end + + assert_equal [], @connection.foreign_keys("testings") + end + + test "foreign keys accept options when changing the table" do + @connection.change_table :testing_parents do |t| + t.references :other, index: { unique: true } + end + @connection.create_table :testings + @connection.change_table :testings do |t| + t.references :testing_parent, foreign_key: { primary_key: :other_id } + end + + fk = @connection.foreign_keys("testings").find { |k| k.to_table == "testing_parents" } + assert_equal "other_id", fk.primary_key + end + + test "foreign keys cannot be added to polymorphic relations when changing the table" do + @connection.create_table :testings + @connection.change_table :testings do |t| + assert_raises(ArgumentError) do + t.references :testing_parent, polymorphic: true, foreign_key: true + end + end + end + + test "foreign key column can be removed" do + @connection.create_table :testings do |t| + t.references :testing_parent, index: true, foreign_key: true + end + + assert_difference "@connection.foreign_keys('testings').size", -1 do + @connection.remove_reference :testings, :testing_parent, foreign_key: true + end + end + + test "removing column removes foreign key" do + @connection.create_table :testings do |t| + t.references :testing_parent, index: true, foreign_key: true + end + + assert_difference "@connection.foreign_keys('testings').size", -1 do + @connection.remove_column :testings, :testing_parent_id + end + end + + test "foreign key methods respect pluralize_table_names" do + original_pluralize_table_names = ActiveRecord::Base.pluralize_table_names + ActiveRecord::Base.pluralize_table_names = false + @connection.create_table :testing + @connection.change_table :testing_parents do |t| + t.references :testing, foreign_key: true + end + + fk = @connection.foreign_keys("testing_parents").first + assert_equal "testing_parents", fk.from_table + assert_equal "testing", fk.to_table + + assert_difference "@connection.foreign_keys('testing_parents').size", -1 do + @connection.remove_reference :testing_parents, :testing, foreign_key: true + end + ensure + ActiveRecord::Base.pluralize_table_names = original_pluralize_table_names + @connection.drop_table "testing", if_exists: true + end + + class CreateDogsMigration < ActiveRecord::Migration::Current + def change + create_table :dog_owners + + create_table :dogs do |t| + t.references :dog_owner, foreign_key: true + end + end + end + + def test_references_foreign_key_with_prefix + ActiveRecord::Base.table_name_prefix = "p_" + migration = CreateDogsMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("p_dogs").size + ensure + silence_stream($stdout) { migration.migrate(:down) } + ActiveRecord::Base.table_name_prefix = nil + end + + def test_references_foreign_key_with_suffix + ActiveRecord::Base.table_name_suffix = "_s" + migration = CreateDogsMigration.new + silence_stream($stdout) { migration.migrate(:up) } + assert_equal 1, @connection.foreign_keys("dogs_s").size + ensure + silence_stream($stdout) { migration.migrate(:down) } + ActiveRecord::Base.table_name_suffix = nil + end + + test "multiple foreign keys can be added to the same table" do + @connection.create_table :testings do |t| + t.references :parent1, foreign_key: { to_table: :testing_parents } + t.references :parent2, foreign_key: { to_table: :testing_parents } + end + + fks = @connection.foreign_keys("testings").sort_by(&:column) + + fk_definitions = fks.map { |fk| [fk.from_table, fk.to_table, fk.column] } + assert_equal([["testings", "testing_parents", "parent1_id"], + ["testings", "testing_parents", "parent2_id"]], fk_definitions) + end + + test "multiple foreign keys can be removed to the selected one" do + @connection.create_table :testings do |t| + t.references :parent1, foreign_key: { to_table: :testing_parents } + t.references :parent2, foreign_key: { to_table: :testing_parents } + end + + assert_difference "@connection.foreign_keys('testings').size", -1 do + @connection.remove_reference :testings, :parent1, foreign_key: { to_table: :testing_parents } + end + + fks = @connection.foreign_keys("testings").sort_by(&:column) + + fk_definitions = fks.map { |fk| [fk.from_table, fk.to_table, fk.column] } + assert_equal([["testings", "testing_parents", "parent2_id"]], fk_definitions) + end + end + end + end +else + class ReferencesWithoutForeignKeySupportTest < ActiveRecord::TestCase + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:testing_parents, force: true) + end + + teardown do + @connection.drop_table("testings", if_exists: true) + @connection.drop_table("testing_parents", if_exists: true) + end + + test "ignores foreign keys defined with the table" do + @connection.create_table :testings do |t| + t.references :testing_parent, foreign_key: true + end + + assert_includes @connection.data_sources, "testings" + 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..e41377d817 --- /dev/null +++ b/activerecord/test/cases/migration/references_index_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +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_creates_index_by_default_even_if_index_option_is_not_passed + connection.create_table table_name do |t| + t.references :foo + end + + assert 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_type, :foo_id], name: :index_testings_on_foo_type_and_foo_id) + 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_creates_index_for_existing_table_even_if_index_option_is_not_passed + connection.create_table table_name + connection.change_table table_name do |t| + t.references :foo + 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_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_type, :foo_id], name: :index_testings_on_foo_type_and_foo_id) + 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..769241ba12 --- /dev/null +++ b/activerecord/test/cases/migration/references_statements_test.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "cases/migration/helper" + +module ActiveRecord + class Migration + class ReferencesStatementsTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_tests = 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_does_not_create_reference_id_index_if_index_is_false + add_reference table_name, :user, index: false + assert_not index_exists?(table_name, :user_id) + end + + def test_create_reference_id_index_even_if_index_option_is_not_passed + add_reference table_name, :user + assert 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_type, :taggable_id]) + 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_reference_type_column_with_not_null + connection.create_table table_name, force: true do |t| + t.references :taggable, null: false, polymorphic: true + end + assert column_exists?(table_name, :taggable_id, :integer, null: false) + assert column_exists?(table_name, :taggable_type, :string, null: false) + end + + def test_does_not_share_options_with_reference_type_column + add_reference table_name, :taggable, type: :integer, limit: 2, polymorphic: true + assert column_exists?(table_name, :taggable_id, :integer, limit: 2) + assert column_exists?(table_name, :taggable_type, :string) + assert_not column_exists?(table_name, :taggable_type, :string, limit: 2) + 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_named_unique_index + add_reference table_name, :tag, index: { name: "index_taggings_on_tag_id", unique: true } + assert index_exists?(table_name, :tag_id, name: "index_taggings_on_tag_id", unique: true) + 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..a9deb92585 --- /dev/null +++ b/activerecord/test/cases/migration/rename_table_test.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "cases/migration/helper" + +module ActiveRecord + class Migration + class RenameTableTest < ActiveRecord::TestCase + include ActiveRecord::Migration::TestHelper + + self.use_transactional_tests = 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 + + unless current_adapter?(:FbAdapter) # Firebird cannot rename tables + 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_includes index.columns, "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 + 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_renames_primary_key + connection.create_table :cats, id: :uuid, default: "uuid_generate_v4()" + rename_table :cats, :felines + + assert connection.table_exists? :felines + assert_not connection.table_exists? :cats + + primary_key_name = connection.select_values(<<~SQL, "SCHEMA")[0] + SELECT c.relname + FROM pg_class c + JOIN pg_index i + ON c.oid = i.indexrelid + WHERE i.indisprimary + AND i.indrelid = 'felines'::regclass + SQL + + assert_equal "felines_pkey", primary_key_name + ensure + connection.drop_table :cats, if_exists: true + connection.drop_table :felines, if_exists: true + end + + def test_renaming_table_doesnt_attempt_to_rename_non_existent_sequences + connection.create_table :cats, id: :uuid, default: "uuid_generate_v4()" + assert_nothing_raised { rename_table :cats, :felines } + assert connection.table_exists? :felines + assert_not connection.table_exists? :cats + ensure + connection.drop_table :cats, if_exists: true + connection.drop_table :felines, if_exists: true + end + 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..8b0ecd2516 --- /dev/null +++ b/activerecord/test/cases/migration_test.rb @@ -0,0 +1,1180 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/migration/helper" +require "bigdecimal/util" +require "concurrent/atomic/count_down_latch" + +require "models/person" +require "models/topic" +require "models/developer" +require "models/computer" + +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, :integer + end + attribute :my_house_population, :integer +end + +class Reminder < ActiveRecord::Base; end + +class Thing < ActiveRecord::Base; end + +class MigrationTest < ActiveRecord::TestCase + self.use_transactional_tests = 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 + @verbose_was, ActiveRecord::Migration.verbose = ActiveRecord::Migration.verbose, false + ActiveRecord::Base.connection.schema_cache.clear! + end + + teardown do + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + + ActiveRecord::SchemaMigration.create_table + ActiveRecord::SchemaMigration.delete_all + + %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 + + ActiveRecord::Migration.verbose = @verbose_was + end + + def test_migrator_migrations_path_is_deprecated + assert_deprecated do + ActiveRecord::Migrator.migrations_path = "/whatever" + end + ensure + assert_deprecated do + ActiveRecord::Migrator.migrations_path = "db/migrate" + end + end + + def test_migration_version_matches_component_version + assert_equal ActiveRecord::VERSION::STRING.to_f, ActiveRecord::Migration.current_version + end + + def test_migrator_versions + migrations_path = MIGRATIONS_ROOT + "/valid" + migrator = ActiveRecord::MigrationContext.new(migrations_path) + + migrator.up + assert_equal 3, migrator.current_version + assert_equal false, migrator.needs_migration? + + migrator.down + assert_equal 0, migrator.current_version + assert_equal true, migrator.needs_migration? + + ActiveRecord::SchemaMigration.create!(version: 3) + assert_equal true, migrator.needs_migration? + end + + def test_migration_detection_without_schema_migration_table + ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true + + migrations_path = MIGRATIONS_ROOT + "/valid" + migrator = ActiveRecord::MigrationContext.new(migrations_path) + + assert_equal true, migrator.needs_migration? + end + + def test_any_migrations + migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid") + + assert_predicate migrator, :any_migrations? + + migrator_empty = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/empty") + + assert_not_predicate migrator_empty, :any_migrations? + end + + def test_migration_version + migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/version_check") + assert_equal 0, migrator.current_version + migrator.up(20131219224947) + assert_equal 20131219224947, migrator.current_version + end + + def test_create_table_raises_if_already_exists + connection = Person.connection + connection.create_table :testings, force: true do |t| + t.string :foo + end + + assert_raise(ActiveRecord::StatementInvalid) do + connection.create_table :testings do |t| + t.string :foo + end + end + ensure + connection.drop_table :testings, if_exists: true + end + + def test_create_table_with_if_not_exists_true + connection = Person.connection + connection.create_table :testings, force: true do |t| + t.string :foo + end + + assert_nothing_raised do + connection.create_table :testings, if_not_exists: true do |t| + t.string :foo + end + end + ensure + connection.drop_table :testings, if_exists: true + end + + def test_create_table_with_force_true_does_not_drop_nonexisting_table + # 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, if_exists: true + end + + def test_migration_instance_has_connection + migration = Class.new(ActiveRecord::Migration::Current).new + assert_equal ActiveRecord::Base.connection, migration.connection + end + + def test_method_missing_delegates_to_connection + migration = Class.new(ActiveRecord::Migration::Current) { + 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_not_predicate BigNumber, :table_exists? + GiveMeBigNumbers.up + BigNumber.reset_column_information + + assert BigNumber.create( + bank_balance: 1586.43, + big_bank_balance: BigDecimal("1000234000567.95"), + world_population: 2**62, + 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 + + assert_kind_of Integer, b.world_population + assert_equal 2**62, b.world_population + assert_kind_of Integer, 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 + 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 Integer, 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_not_predicate Reminder, :table_exists? + + name_filter = lambda { |migration| migration.name == "ValidPeopleHaveLastNames" } + migrator = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid") + migrator.up(&name_filter) + + assert_column Person, :last_name + assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } + + migrator.down(&name_filter) + + assert_no_column Person, :last_name + assert_raise(ActiveRecord::StatementInvalid) { Reminder.first } + end + + class MockMigration < ActiveRecord::Migration::Current + 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_not migration.went_up, "have not gone up" + assert_not migration.went_down, "have not gone down" + + migration.migrate :up + assert migration.went_up, "have gone up" + assert_not migration.went_down, "have not gone down" + end + + def test_instance_based_migration_down + migration = MockMigration.new + assert_not migration.went_up, "have not gone up" + assert_not migration.went_down, "have not gone down" + + migration.migrate :down + assert_not 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::Current) { + 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::Current) { + 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 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_migration_without_transaction + assert_no_column Person, :last_name + + migration = Class.new(ActiveRecord::Migration::Current) { + 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::Base.schema_migrations_table_name + + assert_equal "schema_migrations", ActiveRecord::SchemaMigration.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::SchemaMigration.table_name + ActiveRecord::Base.schema_migrations_table_name = "changed" + Reminder.reset_table_name + assert_equal "prefix_changed_suffix", ActiveRecord::SchemaMigration.table_name + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + Reminder.reset_table_name + assert_equal "changed", ActiveRecord::SchemaMigration.table_name + ensure + ActiveRecord::Base.schema_migrations_table_name = original_schema_migrations_table_name + Reminder.reset_table_name + end + + def test_internal_metadata_table_name + original_internal_metadata_table_name = ActiveRecord::Base.internal_metadata_table_name + + assert_equal "ar_internal_metadata", ActiveRecord::InternalMetadata.table_name + ActiveRecord::Base.table_name_prefix = "p_" + ActiveRecord::Base.table_name_suffix = "_s" + Reminder.reset_table_name + assert_equal "p_ar_internal_metadata_s", ActiveRecord::InternalMetadata.table_name + ActiveRecord::Base.internal_metadata_table_name = "changed" + Reminder.reset_table_name + assert_equal "p_changed_s", ActiveRecord::InternalMetadata.table_name + ActiveRecord::Base.table_name_prefix = "" + ActiveRecord::Base.table_name_suffix = "" + Reminder.reset_table_name + assert_equal "changed", ActiveRecord::InternalMetadata.table_name + ensure + ActiveRecord::Base.internal_metadata_table_name = original_internal_metadata_table_name + Reminder.reset_table_name + end + + def test_internal_metadata_stores_environment + current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + migrations_path = MIGRATIONS_ROOT + "/valid" + migrator = ActiveRecord::MigrationContext.new(migrations_path) + + migrator.up + assert_equal current_env, ActiveRecord::InternalMetadata[:environment] + + original_rails_env = ENV["RAILS_ENV"] + original_rack_env = ENV["RACK_ENV"] + ENV["RAILS_ENV"] = ENV["RACK_ENV"] = "foofoo" + new_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + + assert_not_equal current_env, new_env + + sleep 1 # mysql by default does not store fractional seconds in the database + migrator.up + assert_equal new_env, ActiveRecord::InternalMetadata[:environment] + ensure + ENV["RAILS_ENV"] = original_rails_env + ENV["RACK_ENV"] = original_rack_env + migrator.up + end + + def test_internal_metadata_stores_environment_when_other_data_exists + ActiveRecord::InternalMetadata.delete_all + ActiveRecord::InternalMetadata[:foo] = "bar" + + current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + migrations_path = MIGRATIONS_ROOT + "/valid" + + current_env = ActiveRecord::ConnectionHandling::DEFAULT_ENV.call + migrator = ActiveRecord::MigrationContext.new(migrations_path) + migrator.up + assert_equal current_env, ActiveRecord::InternalMetadata[:environment] + assert_equal "bar", ActiveRecord::InternalMetadata[:foo] + 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_not_predicate 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 + Thing.reset_column_information + + 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_not_predicate Reminder, :table_exists? + ActiveRecord::Base.table_name_prefix = "prefix_" + ActiveRecord::Base.table_name_suffix = "_suffix" + Reminder.reset_table_name + Reminder.reset_sequence_name + Reminder.reset_column_information + 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 + 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 + ensure + Person.connection.drop_table :binary_testings, if_exists: true + end + + unless mysql_enforcing_gtid_consistency? + def test_create_table_with_query + Person.connection.create_table :table_from_query_testings, as: "SELECT id FROM people WHERE id = 1" + + columns = Person.connection.columns(:table_from_query_testings) + assert_equal [1], Person.connection.select_values("SELECT * FROM table_from_query_testings") + assert_equal 1, columns.length + assert_equal "id", columns.first.name + ensure + Person.connection.drop_table :table_from_query_testings rescue nil + end + + def test_create_table_with_query_from_relation + Person.connection.create_table :table_from_query_testings, as: Person.select(:id).where(id: 1) + + columns = Person.connection.columns(:table_from_query_testings) + assert_equal [1], Person.connection.select_values("SELECT * FROM table_from_query_testings") + assert_equal 1, columns.length + assert_equal "id", columns.first.name + ensure + Person.connection.drop_table :table_from_query_testings rescue nil + end + end + + if current_adapter?(:SQLite3Adapter) + def test_allows_sqlite3_rollback_on_invalid_column_type + Person.connection.create_table :something, force: true do |t| + t.column :number, :integer + t.column :name, :string + t.column :foo, :bar + end + assert Person.connection.column_exists?(:something, :foo) + assert_nothing_raised { Person.connection.remove_column :something, :foo, :bar } + assert_not Person.connection.column_exists?(:something, :foo) + assert Person.connection.column_exists?(:something, :name) + assert Person.connection.column_exists?(:something, :number) + ensure + Person.connection.drop_table :something, if_exists: true + end + 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 + 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 + + # should be all good w/ a custom sequence name + assert_nothing_raised do + 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 + + # 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?(:Mysql2Adapter, :PostgreSQLAdapter) + def test_out_of_range_integer_limit_should_raise + e = 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 + + assert_match(/No integer type has byte size 10/, e.message) + ensure + Person.connection.drop_table :test_integer_limits, if_exists: true + end + end + + if current_adapter?(:Mysql2Adapter) + def test_out_of_range_text_limit_should_raise + e = assert_raise(ActiveRecord::ActiveRecordError, "text limit didn't raise") do + Person.connection.create_table :test_text_limits, force: true do |t| + t.text :bigtext, limit: 0xfffffffff + end + end + + assert_match(/No text type has byte length #{0xfffffffff}/, e.message) + ensure + Person.connection.drop_table :test_text_limits, if_exists: true + end + end + + if ActiveRecord::Base.connection.supports_advisory_locks? + def test_migrator_generates_valid_lock_id + migration = Class.new(ActiveRecord::Migration::Current).new + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + + lock_id = migrator.send(:generate_migrator_advisory_lock_id) + + assert ActiveRecord::Base.connection.get_advisory_lock(lock_id), + "the Migrator should have generated a valid lock id, but it didn't" + assert ActiveRecord::Base.connection.release_advisory_lock(lock_id), + "the Migrator should have generated a valid lock id, but it didn't" + end + + def test_generate_migrator_advisory_lock_id + # It is important we are consistent with how we generate this so that + # exclusive locking works across migrator versions + migration = Class.new(ActiveRecord::Migration::Current).new + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + + lock_id = migrator.send(:generate_migrator_advisory_lock_id) + + current_database = ActiveRecord::Base.connection.current_database + salt = ActiveRecord::Migrator::MIGRATOR_SALT + expected_id = Zlib.crc32(current_database) * salt + + assert lock_id == expected_id, "expected lock id generated by the migrator to be #{expected_id}, but it was #{lock_id} instead" + assert lock_id.bit_length <= 63, "lock id must be a signed integer of max 63 bits magnitude" + end + + def test_migrator_one_up_with_unavailable_lock + assert_no_column Person, :last_name + + migration = Class.new(ActiveRecord::Migration::Current) { + def version; 100 end + def migrate(x) + add_column "people", "last_name", :string + end + }.new + + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + lock_id = migrator.send(:generate_migrator_advisory_lock_id) + + with_another_process_holding_lock(lock_id) do + assert_raise(ActiveRecord::ConcurrentMigrationError) { migrator.migrate } + end + + assert_no_column Person, :last_name, + "without an advisory lock, the Migrator should not make any changes, but it did." + end + + def test_migrator_one_up_with_unavailable_lock_using_run + assert_no_column Person, :last_name + + migration = Class.new(ActiveRecord::Migration::Current) { + def version; 100 end + def migrate(x) + add_column "people", "last_name", :string + end + }.new + + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + lock_id = migrator.send(:generate_migrator_advisory_lock_id) + + with_another_process_holding_lock(lock_id) do + assert_raise(ActiveRecord::ConcurrentMigrationError) { migrator.run } + end + + assert_no_column Person, :last_name, + "without an advisory lock, the Migrator should not make any changes, but it did." + end + + def test_with_advisory_lock_raises_the_right_error_when_it_fails_to_release_lock + migration = Class.new(ActiveRecord::Migration::Current).new + migrator = ActiveRecord::Migrator.new(:up, [migration], 100) + lock_id = migrator.send(:generate_migrator_advisory_lock_id) + + e = assert_raises(ActiveRecord::ConcurrentMigrationError) do + silence_stream($stderr) do + migrator.send(:with_advisory_lock) do + ActiveRecord::Base.connection.release_advisory_lock(lock_id) + end + end + end + + assert_match( + /#{ActiveRecord::ConcurrentMigrationError::RELEASE_LOCK_FAILED_MESSAGE}/, + e.message + ) + end + end + + private + # 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 + + def with_another_process_holding_lock(lock_id) + thread_lock = Concurrent::CountDownLatch.new + test_terminated = Concurrent::CountDownLatch.new + + other_process = Thread.new do + conn = ActiveRecord::Base.connection_pool.checkout + conn.get_advisory_lock(lock_id) + thread_lock.count_down + test_terminated.wait # hold the lock open until we tested everything + ensure + conn.release_advisory_lock(lock_id) + ActiveRecord::Base.connection_pool.checkin(conn) + end + + thread_lock.wait # wait until the 'other process' has the lock + + yield + + test_terminated.count_down + other_process.join + 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 + ensure + 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 do + connection.add_index :values, :value, name: "a_different_name" + connection.remove_index :values, column: :value, name: "a_different_name" + end + ensure + 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 + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 1, + "PostgreSQLAdapter" => 2, # one for bulk change, one for comment + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count) do + with_bulk_change_table do |t| + t.column :name, :string + t.string :qualification, :experience + t.integer :age, default: 0 + t.date :birthdate, comment: "This is a comment" + t.timestamps null: true + end + end + + assert_equal 8, columns.size + [:name, :qualification, :experience].each { |s| assert_equal :string, column(s).type } + assert_equal "0", column(:age).default + assert_equal "This is a comment", column(:birthdate).comment + 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_not 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 + + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not + "PostgreSQLAdapter" => 2, + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count) 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_not 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) + + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 3, # Adding an index fires a query every time to check if an index already exists or not + "PostgreSQLAdapter" => 2, + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count) do + with_bulk_change_table do |t| + t.remove_index :name + t.index :name, name: :new_name_index, unique: true + end + end + + assert_not 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_not column(:name).default + assert_equal :date, column(:birthdate).type + + classname = ActiveRecord::Base.connection.class.name[/[^:]*$/] + expected_query_count = { + "Mysql2Adapter" => 3, # one query for columns, one query for primary key, one query to do the bulk change + "PostgreSQLAdapter" => 3, # one query for columns, one for bulk change, one for comment + }.fetch(classname) { + raise "need an expected query count for #{classname}" + } + + assert_queries(expected_query_count, ignore_none: true) do + with_bulk_change_table do |t| + t.change :name, :string, default: "NONAME" + t.change :birthdate, :datetime, comment: "This is a comment" + end + end + + assert_equal "NONAME", column(:name).default + assert_equal :datetime, column(:birthdate).type + assert_equal "This is a comment", column(:birthdate).comment + end + + private + + 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 + include ActiveSupport::Testing::Stream + + 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")[1].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_empty copied + 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_empty copied + 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_empty copied + 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 = "# frozen_string_literal: true\n# 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..2].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_empty copied + 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 + + def test_unknown_migration_version_should_raise_an_argument_error + assert_raise(ArgumentError) { ActiveRecord::Migration[1.0] } + end +end diff --git a/activerecord/test/cases/migrator_test.rb b/activerecord/test/cases/migrator_test.rb new file mode 100644 index 0000000000..30e199f1c5 --- /dev/null +++ b/activerecord/test/cases/migrator_test.rb @@ -0,0 +1,508 @@ +# frozen_string_literal: true + +require "cases/helper" +require "cases/migration/helper" + +class MigratorTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + # Use this class to sense if migrations have gone + # up or down. + class Sensor < ActiveRecord::Migration::Current + 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 + @verbose_was = ActiveRecord::Migration.verbose + ActiveRecord::Migration.message_count = 0 + ActiveRecord::Migration.class_eval do + undef :puts + def puts(*) + ActiveRecord::Migration.message_count += 1 + end + end + end + + teardown do + ActiveRecord::SchemaMigration.delete_all rescue nil + ActiveRecord::Migration.verbose = @verbose_was + ActiveRecord::Migration.class_eval do + undef :puts + def puts(*) + super + end + end + end + + def test_migrator_with_duplicate_names + e = assert_raises(ActiveRecord::DuplicateMigrationNameError) do + list = [ActiveRecord::Migration.new("Chunky"), ActiveRecord::Migration.new("Chunky")] + ActiveRecord::Migrator.new(:up, list) + end + assert_match(/Multiple migrations have the name Chunky/, e.message) + end + + def test_migrator_with_duplicate_versions + assert_raises(ActiveRecord::DuplicateMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 1)] + ActiveRecord::Migrator.new(:up, list) + end + end + + def test_migrator_with_missing_version_numbers + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 3).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, -1).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 0).run + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, 3).migrate + end + + assert_raises(ActiveRecord::UnknownMigrationVersionError) do + list = [ActiveRecord::Migration.new("Foo", 1), ActiveRecord::Migration.new("Bar", 2)] + ActiveRecord::Migrator.new(:up, list, -1).migrate + end + end + + def test_finds_migrations + migrations = ActiveRecord::MigrationContext.new(MIGRATIONS_ROOT + "/valid").migrations + + [[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::MigrationContext.new(MIGRATIONS_ROOT + "/valid_with_subdirectories").migrations + + [[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::MigrationContext.new(directories).migrations + + [[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::MigrationContext.new(MIGRATIONS_ROOT + "/10_urban").migrations + assert_equal 9, migrations[0].version + assert_equal "AddExpressions", migrations[0].name + end + + def test_relative_migrations + list = Dir.chdir(MIGRATIONS_ROOT) do + ActiveRecord::MigrationContext.new("valid").migrations + 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 = [ActiveRecord::Migration.new("foo", 1), ActiveRecord::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_migrations_status + path = MIGRATIONS_ROOT + "/valid" + + ActiveRecord::SchemaMigration.create(version: 2) + ActiveRecord::SchemaMigration.create(version: 10) + + assert_equal [ + ["down", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["down", "003", "Innocent jointable"], + ["up", "010", "********** NO FILE **********"], + ], ActiveRecord::MigrationContext.new(path).migrations_status + end + + def test_migrations_status_in_subdirectories + path = MIGRATIONS_ROOT + "/valid_with_subdirectories" + + ActiveRecord::SchemaMigration.create(version: 2) + ActiveRecord::SchemaMigration.create(version: 10) + + assert_equal [ + ["down", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["down", "003", "Innocent jointable"], + ["up", "010", "********** NO FILE **********"], + ], ActiveRecord::MigrationContext.new(path).migrations_status + end + + def test_migrations_status_with_schema_define_in_subdirectories + path = MIGRATIONS_ROOT + "/valid_with_subdirectories" + prev_paths = ActiveRecord::Migrator.migrations_paths + ActiveRecord::Migrator.migrations_paths = path + + ActiveRecord::Schema.define(version: 3) do + end + + assert_equal [ + ["up", "001", "Valid people have last names"], + ["up", "002", "We need reminders"], + ["up", "003", "Innocent jointable"], + ], ActiveRecord::MigrationContext.new(path).migrations_status + ensure + ActiveRecord::Migrator.migrations_paths = prev_paths + end + + def test_migrations_status_from_two_directories + paths = [MIGRATIONS_ROOT + "/valid_with_timestamps", MIGRATIONS_ROOT + "/to_copy_with_timestamps"] + + ActiveRecord::SchemaMigration.create(version: "20100101010101") + ActiveRecord::SchemaMigration.create(version: "20160528010101") + + assert_equal [ + ["down", "20090101010101", "People have hobbies"], + ["down", "20090101010202", "People have descriptions"], + ["up", "20100101010101", "Valid with timestamps people have last names"], + ["down", "20100201010101", "Valid with timestamps we need reminders"], + ["down", "20100301010101", "Valid with timestamps innocent jointable"], + ["up", "20160528010101", "********** NO FILE **********"], + ], ActiveRecord::MigrationContext.new(paths).migrations_status + 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)] + migrator = ActiveRecord::Migrator.new(:up, migrations) + migrator.migrate + assert migrations.all?(&:went_up) + assert migrations.all? { |m| !m.went_down } + assert_equal 2, 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)] + migrator = ActiveRecord::Migrator.new(:down, migrations) + migrator.migrate + assert migrations.all? { |m| !m.went_up } + assert migrations.all?(&:went_down) + assert_equal 0, migrator.current_version + end + + def test_current_version + ActiveRecord::SchemaMigration.create!(version: "1000") + migrator = ActiveRecord::MigrationContext.new("db/migrate") + assert_equal 1000, 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) + migrator = ActiveRecord::Migrator.new(:up, migrations, 1) + assert_equal(0, migrator.current_version) + + migrator.migrate + assert_equal [[:up, 1]], calls + calls.clear + + migrator.migrate + assert_equal [], calls + end + + def test_migrator_double_down + calls, migrations = sensors(3) + migrator = ActiveRecord::Migrator.new(:up, migrations, 1) + + assert_equal 0, migrator.current_version + + migrator.run + assert_equal [[:up, 1]], calls + calls.clear + + migrator = ActiveRecord::Migrator.new(:down, migrations, 1) + migrator.run + assert_equal [[:down, 1]], calls + calls.clear + + migrator.run + assert_equal [], calls + + assert_equal 0, migrator.current_version + end + + def test_migrator_verbosity + _, migrations = sensors(3) + + ActiveRecord::Migration.verbose = true + 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 + end + + def test_migrator_verbosity_off + _, migrations = sensors(3) + + 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 = migrator.new("valid") + + migrator.up(1) + assert_equal [[:up, 1]], calls + calls.clear + + migrator.migrate(0) + assert_equal [[:down, 1]], calls + calls.clear + + migrator.migrate + assert_equal [[:up, 1], [:up, 2], [:up, 3]], calls + end + + def test_migrator_output_when_running_multiple_migrations + _, migrator = migrator_class(3) + migrator = migrator.new("valid") + + result = migrator.migrate + assert_equal(3, result.count) + + # Nothing migrated from duplicate run + result = migrator.migrate + assert_equal(0, result.count) + + result = migrator.rollback + assert_equal(1, result.count) + end + + def test_migrator_output_when_running_single_migration + _, migrator = migrator_class(1) + migrator = migrator.new("valid") + + result = migrator.run(:up, 1) + + assert_equal(1, result.version) + end + + def test_migrator_rollback + _, migrator = migrator_class(3) + migrator = migrator.new("valid") + + migrator.migrate + assert_equal(3, migrator.current_version) + + migrator.rollback + assert_equal(2, migrator.current_version) + + migrator.rollback + assert_equal(1, migrator.current_version) + + migrator.rollback + assert_equal(0, migrator.current_version) + + migrator.rollback + assert_equal(0, migrator.current_version) + end + + def test_migrator_db_has_no_schema_migrations_table + _, migrator = migrator_class(3) + migrator = migrator.new("valid") + + ActiveRecord::Base.connection.drop_table "schema_migrations", if_exists: true + assert_not ActiveRecord::Base.connection.table_exists?("schema_migrations") + migrator.migrate(1) + assert ActiveRecord::Base.connection.table_exists?("schema_migrations") + end + + def test_migrator_forward + _, migrator = migrator_class(3) + migrator = migrator.new("/valid") + migrator.migrate(1) + assert_equal(1, migrator.current_version) + + migrator.forward(2) + assert_equal(3, migrator.current_version) + + migrator.forward + assert_equal(3, 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 = migrator.new("valid") + migrator.migrate + + assert_equal [[:up, 2], [:up, 3]], calls + end + + def test_get_all_versions + _, migrator = migrator_class(3) + migrator = migrator.new("valid") + + migrator.migrate + assert_equal([1, 2, 3], migrator.get_all_versions) + + migrator.rollback + assert_equal([1, 2], migrator.get_all_versions) + + migrator.rollback + assert_equal([1], migrator.get_all_versions) + + migrator.rollback + assert_equal([], migrator.get_all_versions) + end + + private + def m(name, version) + x = Sensor.new name, version + x.extend(Module.new { + define_method(:up) { yield(:up, x); super() } + define_method(:down) { yield(: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(ActiveRecord::MigrationContext) { + define_method(:migrations) { |*| + migrations + } + } + [calls, migrator] + end +end diff --git a/activerecord/test/cases/mixin_test.rb b/activerecord/test/cases/mixin_test.rb new file mode 100644 index 0000000000..fdb8ac6ab3 --- /dev/null +++ b/activerecord/test/cases/mixin_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "cases/helper" + +class Mixin < ActiveRecord::Base +end + +class TouchTest < ActiveRecord::TestCase + fixtures :mixins + + setup do + travel_to Time.now + 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 + stamped.lft_will_change! + stamped.save + + assert_equal Time.now, stamped.updated_at + assert_equal old_updated_at, stamped.created_at + 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 + 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..87455e4fcb --- /dev/null +++ b/activerecord/test/cases/modules_test.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/company_in_module" +require "models/shop" +require "models/developer" +require "models/computer" + +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_not 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 do + firm.client_ids = [MyApplication::Business::Client.first.id] + end + end + + # An eager loading condition to force the eager loading model into the old join model. + def test_eager_loading_in_modules + clients = [] + + assert_nothing_raised 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_not 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_not 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..6f3903eed4 --- /dev/null +++ b/activerecord/test/cases/multiparameter_attributes_test.rb @@ -0,0 +1,399 @@ +# frozen_string_literal: true + +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_equal 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 + 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 + 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 + 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 + 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 + 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 + 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 + Topic.reset_column_information + 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 + ensure + Topic.reset_column_information + end + + def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes_and_invalid_time_params + with_timezone_config aware_attributes: true do + Topic.reset_column_information + attributes = { + "written_on(1i)" => "2004", "written_on(2i)" => "", "written_on(3i)" => "" + } + topic = Topic.find(1) + topic.attributes = attributes + assert_nil topic.written_on + end + ensure + Topic.reset_column_information + 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_not_respond_to topic.written_on, :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] + Topic.reset_column_information + 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_not_respond_to topic.written_on, :time_zone + end + ensure + Topic.skip_time_zone_conversion_for_attributes = [] + Topic.reset_column_information + 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 + Topic.reset_column_information + 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.zone.local(2000, 1, 1, 16, 24, 0), topic.bonus_time + assert_not_predicate topic.bonus_time, :utc? + + attributes = { + "written_on(1i)" => "2000", "written_on(2i)" => "", "written_on(3i)" => "", + "written_on(4i)" => "", "written_on(5i)" => "" + } + topic.attributes = attributes + assert_nil topic.written_on + end + ensure + Topic.reset_column_information + end + + 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_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 + + 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_create_with_multiparameter_attributes_setting_date_attribute + topic = Topic.create_with("written_on(1i)" => "1952", "written_on(2i)" => "3", "written_on(3i)" => "11").new + 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_create_with_multiparameter_attributes_setting_date_and_time_attribute + topic = Topic.create_with( + "written_on(1i)" => "1952", + "written_on(2i)" => "3", + "written_on(3i)" => "11", + "written_on(4i)" => "13", + "written_on(5i)" => "55").new + 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 + + def test_multiparameter_assigned_attributes_did_not_come_from_user + topic = Topic.new( + "written_on(1i)" => "1952", + "written_on(2i)" => "3", + "written_on(3i)" => "11", + "written_on(4i)" => "13", + "written_on(5i)" => "55", + ) + assert_not_predicate topic, :written_on_came_from_user? + 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..f11c441c65 --- /dev/null +++ b/activerecord/test/cases/multiple_db_test.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/entrant" +require "models/bird" +require "models/course" + +class MultipleDbTest < ActiveRecord::TestCase + self.use_transactional_tests = 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_swapping_the_connection + old_spec_name, Course.connection_specification_name = Course.connection_specification_name, "primary" + assert_equal(Entrant.connection, Course.connection) + ensure + Course.connection_specification_name = old_spec_name + 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_connection + assert_same Entrant.connection, Bird.connection + assert_not_same Entrant.connection, Course.connection + end + + unless in_memory_db? + def test_count_on_custom_connection + ActiveRecord::Base.remove_connection + assert_equal 1, College.count + ensure + ActiveRecord::Base.establish_connection :arunit + end + + def test_associations_should_work_when_model_has_no_connection + ActiveRecord::Base.remove_connection + assert_nothing_raised do + College.first.courses.first + end + ensure + ActiveRecord::Base.establish_connection :arunit + 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..bb1c1ea17d --- /dev/null +++ b/activerecord/test/cases/nested_attributes_test.rb @@ -0,0 +1,1120 @@ +# frozen_string_literal: true + +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(&: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_empty pirate.birds_with_reject_all_blank + 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_empty pirate.birds_with_reject_all_blank + 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_raise_an_UnknownAttributeError_for_non_existing_nested_attributes + exception = assert_raise ActiveModel::UnknownAttributeError do + Pirate.new(ship_attributes: { sail: true }) + end + assert_equal "unknown attribute 'sail' for Ship.", 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 { 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_not 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.create(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_empty man.reload.interests + end + + def test_reject_if_is_not_short_circuited_if_allow_destroy_is_false + Pirate.accepts_nested_attributes_for :ship, reject_if: ->(a) { a[:name] == "The Golden Hind" }, allow_destroy: false + + pirate = Pirate.create!(catchphrase: "Stop wastin' me time", ship_attributes: { name: "White Pearl", _destroy: "1" }) + assert_equal "White Pearl", pirate.reload.ship.name + + pirate.update!(ship_attributes: { id: pirate.ship.id, name: "The Golden Hind", _destroy: "1" }) + assert_equal "White Pearl", pirate.reload.ship.name + + pirate.update!(ship_attributes: { id: pirate.ship.id, name: "Black Pearl", _destroy: "1" }) + assert_equal "Black Pearl", pirate.reload.ship.name + 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 { 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 + + def test_should_not_create_duplicates_with_create_with + Man.accepts_nested_attributes_for(:interests) + + assert_difference("Interest.count", 1) do + Man.create_with( + interests_attributes: [{ topic: "Pirate king" }] + ).find_or_create_by!( + name: "Monkey D. Luffy" + ) + end + 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_not_predicate @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_not_predicate @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.stub(:id, "ABC1X") do + @pirate.ship_attributes = { id: @ship.id, name: "Davy Jones Gold Dagger" } + + assert_equal "Davy Jones Gold Dagger", @pirate.ship.name + end + 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(&: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(&: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_predicate @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_not_predicate @pirate.ship, :destroyed? + assert_predicate @pirate.ship, :marked_for_destruction? + + @pirate.save + + assert_predicate @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_not_predicate @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_not_predicate @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.stub(:id, "ABC1X") do + @ship.pirate_attributes = { id: @pirate.id, catchphrase: "Arr" } + + assert_equal "Arr", @ship.pirate.catchphrase + end + 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 { @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(&:empty?) + + @ship.update(pirate_attributes: { id: @ship.pirate.id, _destroy: "1" }) + assert_nothing_raised { @ship.pirate.reload } + ensure + Ship.accepts_nested_attributes_for :pirate, allow_destroy: true, reject_if: proc(&: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 { 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_not_predicate @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_raise_an_UnknownAttributeError_for_non_existing_nested_attributes_for_has_many + exception = assert_raise ActiveModel::UnknownAttributeError do + @pirate.parrots_attributes = [{ peg_leg: true }] + end + assert_equal "unknown attribute 'peg_leg' for Parrot.", exception.message + 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_not_predicate @pirate.send(@association_name), :loaded? + + @pirate.save + assert_not_predicate @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).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).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).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_predicate @pirate.send(@association_name).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.stub(:id, "ABC1X") do + @child_2.stub(:id, "ABC2X") do + @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 + end + 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_raise_RecordNotFound_if_an_id_belonging_to_a_different_record_is_given + other_pirate = Pirate.create! catchphrase: "Ahoy!" + other_child = other_pirate.send(@association_name).create! name: "Buccaneers Servant" + + exception = assert_raise ActiveRecord::RecordNotFound do + @pirate.attributes = { association_getter => [{ id: other_child.id }] } + end + assert_equal "Couldn't find #{@child_1.class.name} with ID=#{other_child.id} 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_not_predicate @pirate.send(@association_name).first, :persisted? + assert_equal "Grace OMalley", @pirate.send(@association_name).first.name + + assert_not_predicate @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 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 { @pirate.send(association_setter, {}) } + assert_nothing_raised { @pirate.send(association_setter, Hash.new) } + + exception = assert_raise ArgumentError do + @pirate.send(association_setter, "foo") + end + assert_equal %{Hash or Array expected for attribute `#{@association_name}`, 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 { @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_not 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(&: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_tests = 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_tests = 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 + + test "circular references do not perform unnecessary queries" do + ship = Ship.new(name: "The Black Rock") + part = ship.parts.build(name: "Stern") + ship.treasures.build(looter: part) + + assert_queries 3 do + ship.save! + end + end + + test "nested singular associations are validated" do + part = ShipPart.new(name: "Stern", ship_attributes: { name: nil }) + + assert_not_predicate part, :valid? + assert_equal ["Ship name can't be blank"], part.errors.full_messages + end +end + +class TestNestedAttributesWithExtend < ActiveRecord::TestCase + setup do + Pirate.accepts_nested_attributes_for :treasures + end + + def test_extend_affects_nested_attributes + pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?") + pirate.treasures_attributes = [{ id: nil }] + assert_equal "from extension", pirate.treasures[0].name + 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..1d26057fdc --- /dev/null +++ b/activerecord/test/cases/nested_attributes_with_callbacks_test.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +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_predicate @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_predicate @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_predicate @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_predicate @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_predicate @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/null_relation_test.rb b/activerecord/test/cases/null_relation_test.rb new file mode 100644 index 0000000000..ee96ea1af6 --- /dev/null +++ b/activerecord/test/cases/null_relation_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/developer" +require "models/comment" +require "models/post" +require "models/topic" + +class NullRelationTest < ActiveRecord::TestCase + fixtures :posts, :comments + + def test_none + assert_no_queries do + assert_equal [], Developer.none + assert_equal [], Developer.all.none + end + end + + def test_none_chainable + Developer.send(:load_schema) + 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 true, Developer.none.none? + assert_equal false, Developer.none.any? + assert_equal false, Developer.none.one? + assert_equal false, Developer.none.many? + 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 + + [:count, :sum].each do |method| + define_method "test_null_relation_#{method}" do + assert_no_queries do + assert_equal 0, Comment.none.public_send(method, :id) + assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id) + end + end + end + + [:average, :minimum, :maximum].each do |method| + define_method "test_null_relation_#{method}" do + assert_no_queries do + assert_nil Comment.none.public_send(method, :id) + assert_equal Hash.new, Comment.none.group(:post_id).public_send(method, :id) + end + end + end + + def test_null_relation_in_where_condition + assert_operator Comment.count, :>, 0 # precondition, make sure there are comments. + assert_equal 0, Comment.where(post_id: Post.none).count + end +end diff --git a/activerecord/test/cases/numeric_data_test.rb b/activerecord/test/cases/numeric_data_test.rb new file mode 100644 index 0000000000..079e664ee4 --- /dev/null +++ b/activerecord/test/cases/numeric_data_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/numeric_data" + +class NumericDataTest < ActiveRecord::TestCase + 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: 2**62, + my_house_population: 3 + ) + assert m.save + + m1 = NumericData.find_by( + bank_balance: 1586.43, + big_bank_balance: BigDecimal("1000234000567.95") + ) + + assert_kind_of Integer, m1.world_population + assert_equal 2**62, m1.world_population + + assert_kind_of Integer, 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_numeric_fields_with_scale + m = NumericData.new( + bank_balance: 1586.43122334, + big_bank_balance: BigDecimal("234000567.952344"), + world_population: 2**62, + my_house_population: 3 + ) + assert m.save + + m1 = NumericData.find_by( + bank_balance: 1586.43122334, + big_bank_balance: BigDecimal("234000567.952344") + ) + + assert_kind_of Integer, m1.world_population + assert_equal 2**62, m1.world_population + + assert_kind_of Integer, 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("234000567.95"), m1.big_bank_balance + end + + if current_adapter?(:PostgreSQLAdapter) + def test_numeric_fields_with_nan + m = NumericData.new( + bank_balance: BigDecimal("NaN"), + big_bank_balance: BigDecimal("NaN"), + world_population: 2**62, + my_house_population: 3 + ) + assert_predicate m.bank_balance, :nan? + assert_predicate m.big_bank_balance, :nan? + assert m.save + + m1 = NumericData.find_by( + bank_balance: BigDecimal("NaN"), + big_bank_balance: BigDecimal("NaN") + ) + + assert_predicate m1.bank_balance, :nan? + assert_predicate m1.big_bank_balance, :nan? + end + end +end diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb new file mode 100644 index 0000000000..4830ff2b5f --- /dev/null +++ b/activerecord/test/cases/persistence_test.rb @@ -0,0 +1,1082 @@ +# frozen_string_literal: true + +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/computer" +require "models/project" +require "models/minimalistic" +require "models/parrot" +require "models/minivan" +require "models/person" +require "models/ship" +require "models/admin" +require "models/admin/user" + +class PersistenceTest < ActiveRecord::TestCase + fixtures :topics, :companies, :developers, :accounts, :minimalistics, :authors, :author_addresses, :posts, :minivans + + 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 [1, 2], updated.map(&:id) + assert_equal "1 updated", Topic.find(1).content + assert_equal "2 updated", Topic.find(2).content + end + + def test_update_many_with_duplicated_ids + updated = Topic.update([1, 1, 2], [ + { "content" => "1 duplicated" }, { "content" => "1 updated" }, { "content" => "2 updated" } + ]) + + assert_equal [1, 1, 2], updated.map(&:id) + assert_equal "1 updated", Topic.find(1).content + assert_equal "2 updated", Topic.find(2).content + end + + def test_update_many_with_invalid_id + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" }, 99999 => {} } + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.update(topic_data.keys, topic_data.values) + end + + assert_not_equal "1 updated", Topic.find(1).content + assert_not_equal "2 updated", Topic.find(2).content + end + + def test_class_level_update_is_affected_by_scoping + topic_data = { 1 => { "content" => "1 updated" }, 2 => { "content" => "2 updated" } } + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.where("1=0").scoping { Topic.update(topic_data.keys, topic_data.values) } + end + + assert_not_equal "1 updated", Topic.find(1).content + assert_not_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_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_increment_updates_counter_in_db_using_offset + a1 = accounts(:signals37) + initial_credit = a1.credit_limit + a2 = Account.find(accounts(:signals37).id) + a1.increment!(:credit_limit) + a2.increment!(:credit_limit) + assert_equal initial_credit + 2, a1.reload.credit_limit + end + + def test_increment_with_touch_updates_timestamps + topic = topics(:first) + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + travel(1.second) do + topic.increment!(:replies_count, touch: true) + end + assert_equal 2, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + end + + def test_increment_with_touch_an_attribute_updates_timestamps + topic = topics(:first) + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + previously_written_on = topic.written_on + travel(1.second) do + topic.increment!(:replies_count, touch: :written_on) + end + assert_equal 2, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + assert_operator previously_written_on, :<, topic.written_on + end + + def test_increment_with_no_arg + topic = topics(:first) + assert_raises(ArgumentError) { topic.increment! } + end + + def test_destroy_many + clients = Client.find([2, 3]) + + assert_difference("Client.count", -2) do + destroyed = Client.destroy([2, 3]) + assert_equal clients, destroyed + assert destroyed.all?(&:frozen?), "destroyed clients should be frozen" + end + end + + def test_destroy_many_with_invalid_id + clients = Client.find([2, 3]) + + assert_raise(ActiveRecord::RecordNotFound) do + Client.destroy([2, 3, 99999]) + end + + assert_equal clients, Client.find([2, 3]) + 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_after_reload_schema_from_cache + Reply.define_attribute_methods + Reply.serialize(:content) # invoke reload_schema_from_cache + 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_not_predicate company, :valid? + original_errors = company.errors + client = company.becomes(Client) + assert_equal original_errors.keys, client.errors.keys + end + + def test_becomes_errors_base + child_class = Class.new(Admin::User) do + store_accessor :settings, :foo + + def self.name; "Admin::ChildUser"; end + end + + admin = Admin::User.new + admin.errors.add :token, :invalid + child = admin.becomes(child_class) + + assert_equal [:token], child.errors.keys + assert_nothing_raised do + child.errors.add :foo, :invalid + end + end + + def test_duped_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_wont_break_mutation_tracking + topic = topics(:first) + reply = topic.becomes(Reply) + + assert_equal 1, topic.id_in_database + assert_empty topic.attributes_in_database + + assert_equal 1, reply.id_in_database + assert_empty reply.attributes_in_database + 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_decrement_with_touch_updates_timestamps + topic = topics(:first) + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + travel(1.second) do + topic.decrement!(:replies_count, touch: true) + end + assert_equal 0, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + end + + def test_decrement_with_touch_an_attribute_updates_timestamps + topic = topics(:first) + assert_equal 1, topic.replies_count + previously_updated_at = topic.updated_at + previously_written_on = topic.written_on + travel(1.second) do + topic.decrement!(:replies_count, touch: :written_on) + end + assert_equal 0, topic.reload.replies_count + assert_operator previously_updated_at, :<, topic.updated_at + assert_operator previously_written_on, :<, topic.written_on + 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_predicate new_developer, :persisted? + assert_not_predicate 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" + ) + topic = topic.dup # reset @new_record + assert_nothing_raised { topic.save } + assert_predicate topic, :persisted? + assert_equal "Another New Topic", topic.reload.title + 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 } + assert_predicate topic_reloaded, :persisted? + assert_equal "A New Topic", topic_reloaded.reload.title + 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_becomes_default_sti_subclass + original_type = Topic.columns_hash["type"].default + ActiveRecord::Base.connection.change_column_default :topics, :type, "Reply" + Topic.reset_column_information + + reply = topics(:second) + assert_instance_of Reply, reply + + topic = reply.becomes(Topic) + assert_instance_of Topic, topic + + ensure + ActiveRecord::Base.connection.change_column_default :topics, :type, original_type + Topic.reset_column_information + 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_update_attribute_does_not_run_sql_if_attribute_is_not_changed + topic = Topic.create(title: "Another New Topic") + assert_no_queries do + assert topic.update_attribute(:title, "Another New Topic") + end + end + + def test_update_does_not_run_sql_if_record_has_not_changed + topic = Topic.create(title: "Another New Topic") + assert_no_queries do + assert topic.update(title: "Another New Topic") + end + 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_delete_isnt_affected_by_scoping + topic = Topic.find(1) + assert_difference("Topic.count", -1) do + Topic.where("1=0").scoping { topic.delete } + end + 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_find_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(99999) } + end + + def test_update_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.update(99999, approved: true) } + end + + def test_destroy_raises_record_not_found_exception + assert_raise(ActiveRecord::RecordNotFound) { Topic.destroy(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_delete_new_record + client = Client.new(name: "37signals") + client.delete + assert_predicate client, :frozen? + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? + assert_raise(RuntimeError) { client.name = "something else" } + end + + def test_delete_record_with_associations + client = Client.find(3) + client.delete + assert_predicate client, :frozen? + assert_kind_of Firm, client.firm + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? + assert_raise(RuntimeError) { client.name = "something else" } + end + + def test_destroy_new_record + client = Client.new(name: "37signals") + client.destroy + assert_predicate client, :frozen? + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? + assert_raise(RuntimeError) { client.name = "something else" } + end + + def test_destroy_record_with_associations + client = Client.find(3) + client.destroy + assert_predicate client, :frozen? + assert_kind_of Firm, client.firm + + assert_not client.save + assert_raise(ActiveRecord::RecordNotSaved) { client.save! } + + assert_predicate client, :frozen? + assert_raise(RuntimeError) { client.name = "something else" } + end + + def test_update_attribute + assert_not_predicate Topic.find(1), :approved? + Topic.find(1).update_attribute("approved", true) + assert_predicate Topic.find(1), :approved? + + Topic.find(1).update_attribute(:approved, false) + assert_not_predicate Topic.find(1), :approved? + + Topic.find(1).update_attribute(:change_approved_before_save, true) + assert_predicate 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_not t.changed?, "topic should not have changed" + assert_not 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_predicate topic, :approved? + topic.reload + assert_predicate topic, :approved? + + topic.update_column(:approved, false) + assert_not_predicate topic, :approved? + topic.reload + assert_not_predicate 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_predicate topic, :approved? + assert_equal "Sebastian Topic", topic.title + topic.reload + assert_predicate 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_not_predicate topic, :approved? + assert_equal "The First Topic", topic.title + + topic.update("approved" => true, "title" => "The First Topic Updated") + topic.reload + assert_predicate topic, :approved? + assert_equal "The First Topic Updated", topic.title + + topic.update(approved: false, title: "The First Topic") + topic.reload + assert_not_predicate topic, :approved? + assert_equal "The First Topic", topic.title + + error = assert_raise(ActiveRecord::RecordNotUnique, ActiveRecord::StatementInvalid) do + topic.update(id: 3, title: "Hm is it possible?") + end + assert_not_nil error.cause + assert_not_equal "Hm is it possible?", Topic.find(3).title + + topic.update(id: 1234) + assert_nothing_raised { topic.reload } + assert_equal topic.title, Topic.find(1234).title + end + + def test_update_attributes + topic = Topic.find(1) + assert_deprecated do + topic.update_attributes("title" => "The First Topic Updated") + end + end + + def test_update_parameters + topic = Topic.find(1) + assert_nothing_raised do + topic.update({}) + end + + assert_raises(ArgumentError) do + topic.update(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 = Reply.find(2) + assert_deprecated do + reply.update_attributes!("title" => "The Second Topic of the day updated") + end + 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 = Topic.destroy(1) + assert_predicate topic, :destroyed? + + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) } + assert_raise(ActiveRecord::RecordNotFound) { Reply.find(should_be_destroyed_reply.id) } + end + + def test_class_level_destroy_is_affected_by_scoping + should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_not_be_destroyed_reply + + assert_raise(ActiveRecord::RecordNotFound) do + Topic.where("1=0").scoping { Topic.destroy(1) } + end + + assert_nothing_raised { Topic.find(1) } + assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) } + end + + def test_class_level_delete + should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_not_be_destroyed_reply + + Topic.delete(1) + assert_raise(ActiveRecord::RecordNotFound) { Topic.find(1) } + assert_nothing_raised { Reply.find(should_not_be_destroyed_reply.id) } + end + + def test_class_level_delete_is_affected_by_scoping + should_not_be_destroyed_reply = Reply.create("title" => "hello", "content" => "world") + Topic.find(1).replies << should_not_be_destroyed_reply + + Topic.where("1=0").scoping { Topic.delete(1) } + assert_nothing_raised { Topic.find(1) } + assert_nothing_raised { Reply.find(should_not_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_predicate post, :new_record? + + post.id = 1 + post.reload + + assert_equal "Welcome to the weblog", post.title + assert_not_predicate post, :new_record? + end + + def test_reload_via_querycache + ActiveRecord::Base.connection.enable_query_cache! + ActiveRecord::Base.connection.clear_query_cache + assert ActiveRecord::Base.connection.query_cache_enabled, "cache should be on" + parrot = Parrot.create(name: "Shane") + + # populate the cache with the SELECT result + found_parrot = Parrot.find(parrot.id) + assert_equal parrot.id, found_parrot.id + + # Manually update the 'name' attribute in the DB directly + assert_equal 1, ActiveRecord::Base.connection.query_cache.length + ActiveRecord::Base.uncached do + found_parrot.name = "Mary" + found_parrot.save + end + + # Now reload, and verify that it gets the DB version, and not the querycache version + found_parrot.reload + assert_equal "Mary", found_parrot.name + + found_parrot = Parrot.find(parrot.id) + assert_equal "Mary", found_parrot.name + ensure + ActiveRecord::Base.connection.disable_query_cache! + end + + def test_save_touch_false + parrot = Parrot.create!( + name: "Bob", + created_at: 1.day.ago, + updated_at: 1.day.ago) + + created_at = parrot.created_at + updated_at = parrot.updated_at + + parrot.name = "Barb" + parrot.save!(touch: false) + assert_equal parrot.created_at, created_at + assert_equal parrot.updated_at, updated_at + end + + def test_reset_column_information_resets_children + child_class = Class.new(Topic) + child_class.new # force schema to load + + ActiveRecord::Base.connection.add_column(:topics, :foo, :string) + Topic.reset_column_information + + # this should redefine attribute methods + child_class.new + + assert child_class.instance_methods.include?(:foo) + assert child_class.instance_methods.include?(:foo_changed?) + assert_equal "bar", child_class.new(foo: :bar).foo + ensure + ActiveRecord::Base.connection.remove_column(:topics, :foo) + Topic.reset_column_information + 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..080aeb0989 --- /dev/null +++ b/activerecord/test/cases/pooled_connections_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/project" +require "timeout" + +class PooledConnectionsTest < ActiveRecord::TestCase + self.use_transactional_tests = 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(&: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 + 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.join + end + end + + def checkout_checkin_connections_loop(pool_size, loops) + ActiveRecord::Base.establish_connection(@connection.merge(pool: pool_size, checkout_timeout: 0.5)) + @connection_count = 0 + @timed_out = 0 + loops.times do + conn = ActiveRecord::Base.connection_pool.checkout + ActiveRecord::Base.connection_pool.checkin conn + @connection_count += 1 + ActiveRecord::Base.connection.data_sources + rescue ActiveRecord::ConnectionTimeoutError + @timed_out += 1 + 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 + + def test_pooled_connection_checkin_two + checkout_checkin_connections_loop 2, 3 + assert_equal 3, @connection_count + assert_equal 0, @timed_out + assert_equal 2, ActiveRecord::Base.connection_pool.connections.size + end + + def test_pooled_connection_remove + ActiveRecord::Base.establish_connection(@connection.merge(pool: 2, checkout_timeout: 0.5)) + old_connection = ActiveRecord::Base.connection + extra_connection = ActiveRecord::Base.connection_pool.checkout + ActiveRecord::Base.connection_pool.remove(extra_connection) + assert_equal ActiveRecord::Base.connection, old_connection + 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..4759d3b6b2 --- /dev/null +++ b/activerecord/test/cases/primary_keys_test.rb @@ -0,0 +1,473 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" +require "models/topic" +require "models/reply" +require "models/subscriber" +require "models/movie" +require "models/keyboard" +require "models/mixed_case_monkey" +require "models/dashboard" +require "models/non_primary_key" + +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 + 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") + 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_nil keyboard.key_number + end + + def test_customized_string_primary_key_settable_before_save + subscriber = Subscriber.new + subscriber.id = "webster123" + assert_equal "webster123", subscriber.id + assert_equal "webster123", subscriber.nick + end + + def test_update_with_non_primary_key_id_column + subscriber = Subscriber.first + subscriber.update(update_count: 1) + subscriber.reload + assert_equal 1, subscriber.update_count + end + + def test_update_columns_with_non_primary_key_id_column + subscriber = Subscriber.first + subscriber.update_columns(id: 1) + assert_not_equal 1, 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" + subscriber.save! + assert_equal("jdoe", subscriber.id) + + subscriberReloaded = Subscriber.find("jdoe") + assert_equal("John Doe", subscriberReloaded.name) + end + + def test_id_column_that_is_not_primary_key + NonPrimaryKey.create!(id: 100) + actual = NonPrimaryKey.find_by(id: 100) + assert_match %r{<NonPrimaryKey id: 100}, actual.inspect + 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_primary_key_returns_value_if_it_exists + klass = Class.new(ActiveRecord::Base) do + self.table_name = "developers" + end + + assert_equal "id", klass.primary_key + end + + def test_primary_key_returns_nil_if_it_does_not_exist + klass = Class.new(ActiveRecord::Base) do + self.table_name = "developers_projects" + end + + assert_nil klass.primary_key + 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 + + def test_create_without_primary_key_no_extra_query + skip if current_adapter?(:OracleAdapter) + + klass = Class.new(ActiveRecord::Base) do + self.table_name = "dashboards" + end + klass.create! # warmup schema cache + assert_queries(3, ignore_none: true) { klass.create! } + end + + if current_adapter?(:PostgreSQLAdapter) + def test_serial_with_quoted_sequence_name + column = MixedCaseMonkey.columns_hash[MixedCaseMonkey.primary_key] + assert_equal "nextval('\"mixed_case_monkeys_monkeyID_seq\"'::regclass)", column.default_function + assert_predicate column, :serial? + end + + def test_serial_with_unquoted_sequence_name + column = Topic.columns_hash[Topic.primary_key] + assert_equal "nextval('topics_id_seq'::regclass)", column.default_function + assert_predicate column, :serial? + end + end +end + +class PrimaryKeyWithNoConnectionTest < ActiveRecord::TestCase + self.use_transactional_tests = 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 + +class PrimaryKeyWithAutoIncrementTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class AutoIncrement < ActiveRecord::Base + end + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table(:auto_increments, if_exists: true) + end + + def test_primary_key_with_integer + @connection.create_table(:auto_increments, id: :integer, force: true) + assert_auto_incremented + end + + def test_primary_key_with_bigint + @connection.create_table(:auto_increments, id: :bigint, force: true) + assert_auto_incremented + end + + private + def assert_auto_incremented + record1 = AutoIncrement.create! + assert_not_nil record1.id + + record1.destroy + + record2 = AutoIncrement.create! + assert_not_nil record2.id + assert_operator record2.id, :>, record1.id + end +end + +class PrimaryKeyAnyTypeTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + self.use_transactional_tests = false + + class Barcode < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table(:barcodes, primary_key: "code", id: :string, limit: 42, force: true) + end + + teardown do + @connection.drop_table(:barcodes, if_exists: true) + end + + def test_any_type_primary_key + assert_equal "code", Barcode.primary_key + + column = Barcode.column_for_attribute(Barcode.primary_key) + assert_not column.null + assert_equal :string, column.type + assert_equal 42, column.limit + ensure + Barcode.reset_column_information + end + + test "schema dump primary key includes type and options" do + schema = dump_table_schema "barcodes" + assert_match %r{create_table "barcodes", primary_key: "code", id: :string, limit: 42}, schema + assert_no_match %r{t\.index \["code"\]}, schema + end + + if current_adapter?(:Mysql2Adapter) && subsecond_precision_supported? + test "schema typed primary key column" do + @connection.create_table(:scheduled_logs, id: :timestamp, precision: 6, force: true) + schema = dump_table_schema("scheduled_logs") + assert_match %r/create_table "scheduled_logs", id: :timestamp, precision: 6/, schema + end + end +end + +class CompositePrimaryKeyTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + self.use_transactional_tests = false + + def setup + @connection = ActiveRecord::Base.connection + @connection.schema_cache.clear! + @connection.create_table(:uber_barcodes, primary_key: ["region", "code"], force: true) do |t| + t.string :region + t.integer :code + end + @connection.create_table(:barcodes_reverse, primary_key: ["code", "region"], force: true) do |t| + t.string :region + t.integer :code + end + @connection.create_table(:travels, primary_key: ["from", "to"], force: true) do |t| + t.string :from + t.string :to + end + end + + def teardown + @connection.drop_table :uber_barcodes, if_exists: true + @connection.drop_table :barcodes_reverse, if_exists: true + @connection.drop_table :travels, if_exists: true + end + + def test_composite_primary_key + assert_equal ["region", "code"], @connection.primary_keys("uber_barcodes") + end + + def test_composite_primary_key_with_reserved_words + assert_equal ["from", "to"], @connection.primary_keys("travels") + end + + def test_composite_primary_key_out_of_order + assert_equal ["code", "region"], @connection.primary_keys("barcodes_reverse") + end + + def test_primary_key_issues_warning + model = Class.new(ActiveRecord::Base) do + def self.table_name + "uber_barcodes" + end + end + warning = capture(:stderr) do + assert_nil model.primary_key + end + assert_match(/WARNING: Active Record does not support composite primary key\./, warning) + end + + def test_collectly_dump_composite_primary_key + schema = dump_table_schema "uber_barcodes" + assert_match %r{create_table "uber_barcodes", primary_key: \["region", "code"\]}, schema + end + + def test_dumping_composite_primary_key_out_of_order + schema = dump_table_schema "barcodes_reverse" + assert_match %r{create_table "barcodes_reverse", primary_key: \["code", "region"\]}, schema + end +end + +class PrimaryKeyIntegerNilDefaultTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + self.use_transactional_tests = false + + def setup + @connection = ActiveRecord::Base.connection + end + + def teardown + @connection.drop_table :int_defaults, if_exists: true + end + + def test_schema_dump_primary_key_integer_with_default_nil + skip if current_adapter?(:SQLite3Adapter) + @connection.create_table(:int_defaults, id: :integer, default: nil, force: true) + schema = dump_table_schema "int_defaults" + assert_match %r{create_table "int_defaults", id: :integer, default: nil}, schema + end + + def test_schema_dump_primary_key_bigint_with_default_nil + @connection.create_table(:int_defaults, id: :bigint, default: nil, force: true) + schema = dump_table_schema "int_defaults" + assert_match %r{create_table "int_defaults", id: :bigint, default: nil}, schema + end +end + +if current_adapter?(:PostgreSQLAdapter, :Mysql2Adapter) + class PrimaryKeyIntegerTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + self.use_transactional_tests = false + + class Widget < ActiveRecord::Base + end + + setup do + @connection = ActiveRecord::Base.connection + @pk_type = current_adapter?(:PostgreSQLAdapter) ? :serial : :integer + end + + teardown do + @connection.drop_table :widgets, if_exists: true + end + + test "primary key column type with serial/integer" do + @connection.create_table(:widgets, id: @pk_type, force: true) + column = @connection.columns(:widgets).find { |c| c.name == "id" } + assert_equal :integer, column.type + assert_not_predicate column, :bigint? + end + + test "primary key with serial/integer are automatically numbered" do + @connection.create_table(:widgets, id: @pk_type, force: true) + widget = Widget.create! + assert_not_nil widget.id + end + + test "schema dump primary key with serial/integer" do + @connection.create_table(:widgets, id: @pk_type, force: true) + schema = dump_table_schema "widgets" + assert_match %r{create_table "widgets", id: :#{@pk_type}, }, schema + end + + if current_adapter?(:Mysql2Adapter) + test "primary key column type with options" do + @connection.create_table(:widgets, id: :primary_key, limit: 4, unsigned: true, force: true) + column = @connection.columns(:widgets).find { |c| c.name == "id" } + assert_predicate column, :auto_increment? + assert_equal :integer, column.type + assert_not_predicate column, :bigint? + assert_predicate column, :unsigned? + + schema = dump_table_schema "widgets" + assert_match %r{create_table "widgets", id: :integer, unsigned: true, }, schema + end + + test "bigint primary key with unsigned" do + @connection.create_table(:widgets, id: :bigint, unsigned: true, force: true) + column = @connection.columns(:widgets).find { |c| c.name == "id" } + assert_predicate column, :auto_increment? + assert_equal :integer, column.type + assert_predicate column, :bigint? + assert_predicate column, :unsigned? + + schema = dump_table_schema "widgets" + assert_match %r{create_table "widgets", id: :bigint, unsigned: true, }, schema + end + 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..04bbc7d136 --- /dev/null +++ b/activerecord/test/cases/query_cache_test.rb @@ -0,0 +1,634 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/task" +require "models/category" +require "models/post" +require "rack" + +class QueryCacheTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + fixtures :tasks, :topics, :categories, :posts, :categories_posts + + class ShouldNotHaveExceptionsLogger < ActiveRecord::LogSubscriber + attr_reader :logger, :events + + def initialize + super + @logger = ::Logger.new File::NULL + @exception = false + @events = [] + end + + def exception? + @exception + end + + def sql(event) + @events << event + super + rescue + @exception = true + end + end + + def teardown + Task.connection.clear_query_cache + ActiveRecord::Base.connection.disable_query_cache! + super + end + + def test_exceptional_middleware_clears_and_disables_cache_on_error + assert_cache :off + + mw = middleware { |env| + Task.find 1 + Task.find 1 + query_cache = ActiveRecord::Base.connection.query_cache + assert_equal 1, query_cache.length, query_cache.keys + raise "lol borked" + } + assert_raises(RuntimeError) { mw.call({}) } + + assert_cache :off + end + + def test_query_cache_is_applied_to_connections_in_all_handlers + ActiveRecord::Base.connection_handlers = { + writing: ActiveRecord::Base.default_connection_handler, + reading: ActiveRecord::ConnectionAdapters::ConnectionHandler.new + } + + ActiveRecord::Base.connected_to(role: :reading) do + ActiveRecord::Base.establish_connection(ActiveRecord::Base.configurations["arunit"]) + end + + mw = middleware { |env| + ro_conn = ActiveRecord::Base.connection_handlers[:reading].connection_pool_list.first.connection + assert_predicate ActiveRecord::Base.connection, :query_cache_enabled + assert_predicate ro_conn, :query_cache_enabled + } + + mw.call({}) + ensure + ActiveRecord::Base.connection_handlers = { writing: ActiveRecord::Base.default_connection_handler } + end + + def test_query_cache_across_threads + with_temporary_connection_pool do + 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. + ActiveRecord::Base.connection_pool.with_connection do |connection| + connection.create_table :tasks do |t| + t.datetime :starting + t.datetime :ending + end + end + ActiveRecord::FixtureSet.create_fixtures(self.class.fixture_path, ["tasks"], {}, ActiveRecord::Base) + end + + ActiveRecord::Base.connection_pool.connections.each do |conn| + assert_cache :off, conn + end + + assert_not_predicate ActiveRecord::Base.connection, :nil? + assert_cache :off + + middleware { + assert_cache :clean + + Task.find 1 + assert_cache :dirty + + thread_1_connection = ActiveRecord::Base.connection + ActiveRecord::Base.clear_active_connections! + assert_cache :off, thread_1_connection + + started = Concurrent::Event.new + checked = Concurrent::Event.new + + thread_2_connection = nil + thread = Thread.new { + thread_2_connection = ActiveRecord::Base.connection + + assert_equal thread_2_connection, thread_1_connection + assert_cache :off + + middleware { + assert_cache :clean + + Task.find 1 + assert_cache :dirty + + started.set + checked.wait + + ActiveRecord::Base.clear_active_connections! + }.call({}) + } + + started.wait + + thread_1_connection = ActiveRecord::Base.connection + assert_not_equal thread_1_connection, thread_2_connection + assert_cache :dirty, thread_2_connection + checked.set + thread.join + + assert_cache :off, thread_2_connection + }.call({}) + + ActiveRecord::Base.connection_pool.connections.each do |conn| + assert_cache :off, conn + end + ensure + ActiveRecord::Base.connection_pool.disconnect! + end + end + + def test_middleware_delegates + called = false + mw = middleware { |env| + called = true + [200, {}, nil] + } + mw.call({}) + assert called, "middleware should delegate" + end + + def test_middleware_caches + mw = middleware { |env| + Task.find 1 + Task.find 1 + query_cache = ActiveRecord::Base.connection.query_cache + assert_equal 1, query_cache.length, query_cache.keys + [200, {}, nil] + } + mw.call({}) + end + + def test_cache_enabled_during_call + assert_cache :off + + mw = middleware { |env| + assert_cache :clean + [200, {}, nil] + } + mw.call({}) + 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_no_queries { 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_exists_queries_with_cache + Post.cache do + assert_queries(1) { Post.exists?; Post.exists? } + end + end + + def test_select_all_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_all(Post.all) } + end + end + end + + def test_select_one_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_one(Post.all) } + end + end + end + + def test_select_value_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_value(Post.all) } + end + end + end + + def test_select_values_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_values(Post.all) } + end + end + end + + def test_select_rows_with_cache + Post.cache do + assert_queries(1) do + 2.times { Post.connection.select_rows(Post.all) } + end + 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_notifications_can_be_overridden + logger = ShouldNotHaveExceptionsLogger.new + subscriber = ActiveSupport::Notifications.subscribe "sql.active_record", logger + + connection = ActiveRecord::Base.connection.dup + + def connection.cache_notification_info(sql, name, binds) + super.merge(neat: true) + end + + connection.cache do + connection.select_all "select 1" + connection.select_all "select 1" + end + + assert_equal true, logger.events.last.payload[:neat] + ensure + ActiveSupport::Notifications.unsubscribe subscriber + end + + def test_cache_does_not_raise_exceptions + logger = ShouldNotHaveExceptionsLogger.new + subscriber = ActiveSupport::Notifications.subscribe "sql.active_record", logger + + ActiveRecord::Base.cache do + assert_queries(1) { Task.find(1); Task.find(1) } + end + + assert_not_predicate logger, :exception? + ensure + ActiveSupport::Notifications.unsubscribe subscriber + end + + def test_query_cache_does_not_allow_sql_key_mutation + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |_, _, _, _, payload| + payload[:sql].downcase! + end + + assert_raises FrozenError do + ActiveRecord::Base.cache do + assert_queries(1) { Task.find(1); Task.find(1) } + end + end + ensure + ActiveSupport::Notifications.unsubscribe subscriber + end + + def test_cache_is_flat + Task.cache do + 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_results_in_arrays + Task.cache do + if current_adapter?(:SQLite3Adapter, :Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter) + assert_equal 2, 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 + + def test_cache_is_available_when_using_a_not_connected_connection + skip "In-Memory DB can't test for using a not connected connection" if in_memory_db? + with_temporary_connection_pool do + spec_name = Task.connection_specification_name + conf = ActiveRecord::Base.configurations["arunit"].merge("name" => "test2") + ActiveRecord::Base.connection_handler.establish_connection(conf) + Task.connection_specification_name = "test2" + assert_not_predicate Task, :connected? + + Task.cache do + assert_queries(1) { Task.find(1); Task.find(1) } + ensure + ActiveRecord::Base.connection_handler.remove_connection(Task.connection_specification_name) + Task.connection_specification_name = spec_name + end + end + end + + def test_query_cache_executes_new_queries_within_block + ActiveRecord::Base.connection.enable_query_cache! + + # Warm up the cache by running the query + assert_queries(1) do + assert_equal 0, Post.where(title: "test").to_a.count + end + + # Check that if the same query is run again, no queries are executed + assert_no_queries do + assert_equal 0, Post.where(title: "test").to_a.count + end + + ActiveRecord::Base.connection.uncached do + # Check that new query is executed, avoiding the cache + assert_queries(1) do + assert_equal 0, Post.where(title: "test").to_a.count + end + end + end + + def test_query_cache_doesnt_leak_cached_results_of_rolled_back_queries + ActiveRecord::Base.connection.enable_query_cache! + post = Post.first + + Post.transaction do + post.update(title: "rollback") + assert_equal 1, Post.where(title: "rollback").to_a.count + raise ActiveRecord::Rollback + end + + assert_equal 0, Post.where(title: "rollback").to_a.count + + ActiveRecord::Base.connection.uncached do + assert_equal 0, Post.where(title: "rollback").to_a.count + end + + begin + Post.transaction do + post.update(title: "rollback") + assert_equal 1, Post.where(title: "rollback").to_a.count + raise "broken" + end + rescue Exception + end + + assert_equal 0, Post.where(title: "rollback").to_a.count + + ActiveRecord::Base.connection.uncached do + assert_equal 0, Post.where(title: "rollback").to_a.count + end + end + + def test_query_cached_even_when_types_are_reset + Task.cache do + # Warm the cache + Task.find(1) + + # Preload the type cache again (so we don't have those queries issued during our assertions) + Task.connection.send(:reload_type_map) + + # Clear places where type information is cached + Task.reset_column_information + Task.initialize_find_by_cache + Task.define_attribute_methods + + assert_no_queries do + Task.find(1) + end + end + end + + def test_query_cache_does_not_establish_connection_if_unconnected + with_temporary_connection_pool do + ActiveRecord::Base.clear_active_connections! + assert_not ActiveRecord::Base.connection_handler.active_connections? # sanity check + + middleware { + assert_not ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in setup" + }.call({}) + + assert_not ActiveRecord::Base.connection_handler.active_connections?, "QueryCache forced ActiveRecord::Base to establish a connection in cleanup" + end + end + + def test_query_cache_is_enabled_on_connections_established_after_middleware_runs + with_temporary_connection_pool do + ActiveRecord::Base.clear_active_connections! + assert_not ActiveRecord::Base.connection_handler.active_connections? # sanity check + + middleware { + assert_predicate ActiveRecord::Base.connection, :query_cache_enabled + }.call({}) + assert_not_predicate ActiveRecord::Base.connection, :query_cache_enabled + end + end + + def test_query_caching_is_local_to_the_current_thread + with_temporary_connection_pool do + ActiveRecord::Base.clear_active_connections! + + middleware { + assert ActiveRecord::Base.connection_pool.query_cache_enabled + assert ActiveRecord::Base.connection.query_cache_enabled + + Thread.new { + assert_not ActiveRecord::Base.connection_pool.query_cache_enabled + assert_not ActiveRecord::Base.connection.query_cache_enabled + }.join + }.call({}) + end + end + + def test_query_cache_is_enabled_on_all_connection_pools + middleware { + ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool| + assert pool.query_cache_enabled + assert pool.connection.query_cache_enabled + end + }.call({}) + end + + private + + def with_temporary_connection_pool + old_pool = ActiveRecord::Base.connection_handler.retrieve_connection_pool(ActiveRecord::Base.connection_specification_name) + new_pool = ActiveRecord::ConnectionAdapters::ConnectionPool.new ActiveRecord::Base.connection_pool.spec + ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = new_pool + + yield + ensure + ActiveRecord::Base.connection_handler.send(:owner_to_pool)["primary"] = old_pool + end + + def middleware(&app) + executor = Class.new(ActiveSupport::Executor) + ActiveRecord::QueryCache.install_executor_hooks executor + lambda { |env| executor.wrap { app.call(env) } } + end + + def assert_cache(state, connection = ActiveRecord::Base.connection) + case state + when :off + assert_not connection.query_cache_enabled, "cache should be off" + assert connection.query_cache.empty?, "cache should be empty" + when :clean + assert connection.query_cache_enabled, "cache should be on" + assert connection.query_cache.empty?, "cache should be empty" + when :dirty + assert connection.query_cache_enabled, "cache should be on" + assert_not connection.query_cache.empty?, "cache should be dirty" + else + raise "unknown state" + end + end +end + +class QueryCacheExpiryTest < ActiveRecord::TestCase + fixtures :tasks, :posts, :categories, :categories_posts + + def teardown + Task.connection.clear_query_cache + end + + 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 + assert_called(Task.connection, :clear_query_cache) do + assert_not Task.connection.query_cache_enabled + Task.cache do + assert Task.connection.query_cache_enabled + Task.find(1) + + Task.uncached do + assert_not Task.connection.query_cache_enabled + Task.find(1) + end + + assert Task.connection.query_cache_enabled + end + assert_not Task.connection.query_cache_enabled + end + end + + def test_update + assert_called(Task.connection, :clear_query_cache, times: 2) do + Task.cache do + task = Task.find(1) + task.starting = Time.now.utc + task.save! + end + end + end + + def test_destroy + assert_called(Task.connection, :clear_query_cache, times: 2) do + Task.cache do + Task.find(1).destroy + end + end + end + + def test_insert + assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do + Task.cache do + Task.create! + end + end + end + + def test_cache_is_expired_by_habtm_update + assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do + ActiveRecord::Base.cache do + c = Category.first + p = Post.first + p.categories << c + end + end + end + + def test_cache_is_expired_by_habtm_delete + assert_called(ActiveRecord::Base.connection, :clear_query_cache, times: 2) do + ActiveRecord::Base.cache do + p = Post.find(1) + assert_predicate p.categories, :any? + p.categories.delete_all + end + end + end + + test "threads use the same connection" do + @connection_1 = ActiveRecord::Base.connection.object_id + + thread_a = Thread.new do + @connection_2 = ActiveRecord::Base.connection.object_id + end + + thread_a.join + + assert_equal @connection_1, @connection_2 + end +end diff --git a/activerecord/test/cases/quoting_test.rb b/activerecord/test/cases/quoting_test.rb new file mode 100644 index 0000000000..723fccc8d9 --- /dev/null +++ b/activerecord/test/cases/quoting_test.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +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 "TRUE", @quoter.quoted_true + end + + def test_quoted_false + assert_equal "FALSE", @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_timestamp_utc + with_timezone_config default: :utc do + t = Time.now.change(usec: 0) + assert_equal t.getutc.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quoted_timestamp_local + with_timezone_config default: :local do + t = Time.now.change(usec: 0) + assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quoted_timestamp_crazy + with_timezone_config default: :asdfasdf do + t = Time.now.change(usec: 0) + assert_equal t.getlocal.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quoted_time_utc + with_timezone_config default: :utc do + t = Time.now.change(usec: 0) + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getutc.to_s(:db).slice(11..-1) + + assert_equal expected, @quoter.quoted_time(t) + end + end + + def test_quoted_time_local + with_timezone_config default: :local do + t = Time.now.change(usec: 0) + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).sub("2000-01-01 ", "") + + assert_equal expected, @quoter.quoted_time(t) + end + end + + def test_quoted_time_dst_utc + with_env_tz "America/New_York" do + with_timezone_config default: :utc do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getutc.to_s(:db).slice(11..-1) + + assert_equal expected, @quoter.quoted_time(t) + end + end + end + + def test_quoted_time_dst_local + with_env_tz "America/New_York" do + with_timezone_config default: :local do + t = Time.new(2000, 7, 1, 0, 0, 0, "+04:30") + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).slice(11..-1) + + assert_equal expected, @quoter.quoted_time(t) + end + end + end + + def test_quoted_time_crazy + with_timezone_config default: :asdfasdf do + t = Time.now.change(usec: 0) + + expected = t.change(year: 2000, month: 1, day: 1) + expected = expected.getlocal.to_s(:db).sub("2000-01-01 ", "") + + assert_equal expected, @quoter.quoted_time(t) + end + end + + def test_quoted_datetime_utc + with_timezone_config default: :utc do + t = Time.now.change(usec: 0).to_datetime + 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 = Time.now.change(usec: 0).to_datetime + assert_equal t.to_s(:db), @quoter.quoted_date(t) + end + end + + def test_quote_nil + assert_equal "NULL", @quoter.quote(nil) + end + + def test_quote_true + assert_equal @quoter.quoted_true, @quoter.quote(true) + end + + def test_quote_false + assert_equal @quoter.quoted_false, @quoter.quote(false) + end + + def test_quote_float + float = 1.2 + assert_equal float.to_s, @quoter.quote(float) + end + + def test_quote_integer + integer = 1 + assert_equal integer.to_s, @quoter.quote(integer) + end + + def test_quote_bignum + bignum = 1 << 100 + assert_equal bignum.to_s, @quoter.quote(bignum) + end + + def test_quote_bigdecimal + bigdec = BigDecimal((1 << 100).to_s) + assert_equal bigdec.to_s("F"), @quoter.quote(bigdec) + end + + def test_dates_and_times + @quoter.extend(Module.new { def quoted_date(value) "lol" end }) + assert_equal "'lol'", @quoter.quote(Date.today) + assert_equal "'lol'", @quoter.quote(Time.now) + assert_equal "'lol'", @quoter.quote(DateTime.now) + end + + def test_quoting_classes + assert_equal "'Object'", @quoter.quote(Object) + end + + def test_crazy_object + crazy = Object.new + e = assert_raises(TypeError) do + @quoter.quote(crazy) + end + assert_equal "can't quote Object", e.message + end + + def test_quote_string_no_column + assert_equal "'lo\\\\l'", @quoter.quote('lo\l') + end + + def test_quote_as_mb_chars_no_column + string = ActiveSupport::Multibyte::Chars.new('lo\l') + assert_equal "'lo\\\\l'", @quoter.quote(string) + end + + def test_quote_duration + assert_equal "1800", @quoter.quote(30.minutes) + end + end + + class TypeCastingTest < ActiveRecord::TestCase + def setup + @conn = ActiveRecord::Base.connection + end + + def test_type_cast_symbol + assert_equal "foo", @conn.type_cast(:foo) + end + + def test_type_cast_date + date = Date.today + if current_adapter?(:Mysql2Adapter) + expected = date + else + expected = @conn.quoted_date(date) + end + assert_equal expected, @conn.type_cast(date) + end + + def test_type_cast_time + time = Time.now + if current_adapter?(:Mysql2Adapter) + expected = time + else + expected = @conn.quoted_date(time) + end + assert_equal expected, @conn.type_cast(time) + end + + def test_type_cast_numeric + assert_equal 10, @conn.type_cast(10) + assert_equal 2.2, @conn.type_cast(2.2) + end + + def test_type_cast_nil + assert_nil @conn.type_cast(nil) + end + + def test_type_cast_unknown_should_raise_error + obj = Class.new.new + assert_raise(TypeError) { @conn.type_cast(obj) } + end + end + + class QuoteBooleanTest < ActiveRecord::TestCase + def setup + @connection = ActiveRecord::Base.connection + end + + def test_quote_returns_frozen_string + assert_predicate @connection.quote(true), :frozen? + assert_predicate @connection.quote(false), :frozen? + end + + def test_type_cast_returns_frozen_value + assert_predicate @connection.type_cast(true), :frozen? + assert_predicate @connection.type_cast(false), :frozen? + end + end + + if subsecond_precision_supported? + class QuoteARBaseTest < ActiveRecord::TestCase + class DatetimePrimaryKey < ActiveRecord::Base + end + + def setup + @time = ::Time.utc(2017, 2, 14, 12, 34, 56, 789999) + @connection = ActiveRecord::Base.connection + @connection.create_table :datetime_primary_keys, id: :datetime, precision: 3, force: true + end + + def teardown + @connection.drop_table :datetime_primary_keys, if_exists: true + end + + def test_quote_ar_object + value = DatetimePrimaryKey.new(id: @time) + assert_equal "'2017-02-14 12:34:56.789000'", @connection.quote(value) + end + + def test_type_cast_ar_object + value = DatetimePrimaryKey.new(id: @time) + assert_equal @connection.type_cast(value.id), @connection.type_cast(value) + end + 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..059fa76132 --- /dev/null +++ b/activerecord/test/cases/readonly_test.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/post" +require "models/comment" +require "models/developer" +require "models/computer" +require "models/project" +require "models/reader" +require "models/person" +require "models/ship" + +class ReadOnlyTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :posts, :comments, :developers, :projects, :developers_projects, :people, :readers + + def test_cant_save_readonly_record + dev = Developer.find(1) + assert_not_predicate dev, :readonly? + + dev.readonly! + assert_predicate dev, :readonly? + + assert_nothing_raised do + dev.name = "Luscious forbidden fruit." + assert_not dev.save + dev.name = "Forbidden." + end + + e = assert_raise(ActiveRecord::ReadOnlyRecord) { dev.save } + assert_equal "Developer is marked as readonly", e.message + + e = assert_raise(ActiveRecord::ReadOnlyRecord) { dev.save! } + assert_equal "Developer is marked as readonly", e.message + + e = assert_raise(ActiveRecord::ReadOnlyRecord) { dev.destroy } + assert_equal "Developer is marked as readonly", e.message + end + + def test_find_with_readonly_option + Developer.all.each { |d| assert_not d.readonly? } + Developer.readonly(false).each { |d| assert_not 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_not_empty post.comments + assert_not post.comments.any?(&:readonly?) + assert_not 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_not people.any?(&:readonly?) + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_by_id + assert_not_predicate posts(:welcome).people.find(1), :readonly? + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_first + assert_not_predicate posts(:welcome).people.first, :readonly? + end + + def test_has_many_with_through_is_not_implicitly_marked_readonly_while_finding_last + assert_not_predicate posts(:welcome).people.last, :readonly? + end + + def test_readonly_scoping + Post.where("1=1").scoping do + assert_not_predicate Post.find(1), :readonly? + assert_predicate Post.readonly(true).find(1), :readonly? + assert_not_predicate Post.readonly(false).find(1), :readonly? + end + + Post.joins(" ").scoping do + assert_not_predicate Post.find(1), :readonly? + assert_predicate Post.readonly.find(1), :readonly? + assert_not_predicate 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_predicate Post.find(1), :readonly? + assert_predicate Post.readonly.find(1), :readonly? + assert_not_predicate Post.readonly(false).find(1), :readonly? + end + end + + Post.readonly(true).scoping do + assert_predicate Post.find(1), :readonly? + assert_predicate Post.readonly.find(1), :readonly? + assert_not_predicate 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_not_predicate developer.projects.all_as_method.first, :readonly? + assert_not_predicate developer.projects.all_as_scope.first, :readonly? + + assert_not_predicate project.comments.all_as_method.first, :readonly? + assert_not_predicate 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..b630f782bc --- /dev/null +++ b/activerecord/test/cases/reaper_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +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 + attr_reader :flushed + + def initialize + @reaped = false + end + + def reap + @reaped = true + end + + def flush + @flushed = true + end + end + + # A reaper with nil time should never reap connections + def test_nil_time + fp = FakePool.new + assert_not fp.reaped + reaper = ConnectionPool::Reaper.new(fp, nil) + reaper.run + assert_not fp.reaped + end + + def test_some_time + fp = FakePool.new + assert_not fp.reaped + + reaper = ConnectionPool::Reaper.new(fp, 0.0001) + reaper.run + until fp.reaped + Thread.pass + end + assert fp.reaped + assert fp.flushed + 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] = "10.01" + pool = ConnectionPool.new spec + assert_equal 10.01, 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_predicate conn, :in_use? + + child.terminate + + while conn.in_use? + Thread.pass + end + assert_not_predicate 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..abadafbad4 --- /dev/null +++ b/activerecord/test/cases/reflection_test.rb @@ -0,0 +1,518 @@ +# frozen_string_literal: true + +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" +require "models/recipe" + +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(&: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(&: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 :string, @first.column_for_attribute(:title).type + assert_equal :string, @first.type_for_attribute("title").type + assert_equal :string, @first.type_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_not 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 + assert_equal :integer, @first.column_for_attribute(:id).type + assert_equal :integer, @first.type_for_attribute("id").type + assert_equal :integer, @first.type_for_attribute(:id).type + end + + def test_non_existent_columns_return_null_object + column = @first.column_for_attribute("attribute_that_doesnt_exist") + assert_instance_of ActiveRecord::ConnectionAdapters::NullColumn, column + assert_equal "attribute_that_doesnt_exist", column.name + assert_nil column.sql_type + assert_nil column.type + + column = @first.column_for_attribute(:attribute_that_doesnt_exist) + assert_instance_of ActiveRecord::ConnectionAdapters::NullColumn, column + end + + def test_non_existent_types_are_identity_types + type = @first.type_for_attribute("attribute_that_doesnt_exist") + object = Object.new + + assert_equal object, type.deserialize(object) + assert_equal object, type.cast(object) + assert_equal object, type.serialize(object) + + type = @first.type_for_attribute(:attribute_that_doesnt_exist) + assert_equal object, type.deserialize(object) + assert_equal object, type.cast(object) + assert_equal object, type.serialize(object) + end + + def test_reflection_klass_for_nested_class_name + reflection = ActiveRecord::Reflection.create( + :has_many, + nil, + nil, + { class_name: "MyApplication::Business::Company" }, + Customer + ) + 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_includes Customer.reflect_on_all_aggregations, reflection_for_gps_location + assert_includes Customer.reflect_on_all_aggregations, reflection_for_balance + assert_includes Customer.reflect_on_all_aggregations, 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_not_empty received + 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_reflections_should_return_keys_as_strings + assert Category.reflections.keys.all? { |key| key.is_a? String }, "Model.reflections is expected to return string for keys" + 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_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.cake_designers.count + assert_equal 1, hotel.drink_designers.size + assert_equal 1, hotel.drink_designers.count + assert_equal 2, hotel.chefs.size + assert_equal 2, hotel.chefs.count + end + + def test_scope_chain_does_not_interfere_with_hmt_with_polymorphic_case_and_sti + hotel = Hotel.create! + hotel.mocktail_designers << MocktailDesigner.create! + + assert_equal 1, hotel.mocktail_designers.size + assert_equal 1, hotel.mocktail_designers.count + assert_equal 1, hotel.chef_lists.size + assert_equal 1, hotel.chef_lists.count + + hotel.mocktail_designers = [] + + assert_equal 0, hotel.mocktail_designers.size + assert_equal 0, hotel.mocktail_designers.count + assert_equal 0, hotel.chef_lists.size + assert_equal 0, hotel.chef_lists.count + end + + def test_scope_chain_of_polymorphic_association_does_not_leak_into_other_hmt_associations + hotel = Hotel.create! + department = hotel.departments.create! + drink = department.chefs.create!(employable: DrinkDesigner.create!) + Recipe.create!(chef_id: drink.id, hotel_id: hotel.id) + + expected_sql = capture_sql { hotel.recipes.to_a } + + Hotel.reflect_on_association(:recipes).clear_association_scope_cache + hotel.reload + hotel.drink_designers.to_a + loaded_sql = capture_sql { hotel.recipes.to_a } + + assert_equal expected_sql, loaded_sql + end + + def test_nested? + assert_not_predicate Author.reflect_on_association(:comments), :nested? + assert_predicate 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_predicate 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_type + assert_equal "taggable_type", Post.reflect_on_association(:taggings).type.to_s + assert_equal "imageable_class", Post.reflect_on_association(:images).type.to_s + assert_nil Post.reflect_on_association(:readers).type + 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 + assert_nil Sponsor.reflect_on_association(:sponsor_club).foreign_type + end + + def test_collection_association + assert_predicate Pirate.reflect_on_association(:birds), :collection? + assert_predicate Pirate.reflect_on_association(:parrots), :collection? + + assert_not_predicate Pirate.reflect_on_association(:ship), :collection? + assert_not_predicate Ship.reflect_on_association(:pirate), :collection? + end + + def test_default_association_validation + assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, {}, Firm), :validate? + + assert_not_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, {}, Firm), :validate? + assert_not_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, {}, Firm), :validate? + end + + def test_always_validate_association_if_explicit + assert_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { validate: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { validate: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { validate: true }, Firm), :validate? + end + + def test_validate_association_if_autosave + assert_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true }, Firm), :validate? + assert_predicate ActiveRecord::Reflection.create(:has_many, :clients, nil, { autosave: true }, Firm), :validate? + end + + def test_never_validate_association_if_explicit + assert_not_predicate ActiveRecord::Reflection.create(:has_one, :client, nil, { autosave: true, validate: false }, Firm), :validate? + assert_not_predicate ActiveRecord::Reflection.create(:belongs_to, :client, nil, { autosave: true, validate: false }, Firm), :validate? + assert_not_predicate 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_symbol_for_class_name + assert_equal Client, Firm.reflect_on_association(:unsorted_clients_with_symbol).klass + end + + def test_class_for_class_name + error = assert_raises(ArgumentError) do + ActiveRecord::Reflection.create(:has_many, :clients, nil, { class_name: Client }, Firm) + end + assert_equal "A class was passed to `:class_name` but we are expecting a string.", error.message + 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.stub(:klass, category) do + assert_equal "categories_products", reflection.join_table + end + + reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, {}, category) + reflection.stub(:klass, product) do + assert_equal "categories_products", reflection.join_table + end + 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.stub(:klass, category) do + assert_equal "catalog_categories_products", reflection.join_table + end + + reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, {}, category) + reflection.stub(:klass, product) do + assert_equal "catalog_categories_products", reflection.join_table + end + 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.stub(:klass, category) do + assert_equal "catalog_categories_content_pages", reflection.join_table + end + + reflection = ActiveRecord::Reflection.create(:has_many, :pages, nil, {}, category) + reflection.stub(:klass, page) do + assert_equal "catalog_categories_content_pages", reflection.join_table + end + 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.stub(:klass, category) do + assert_equal "product_categories", reflection.join_table + end + + reflection = ActiveRecord::Reflection.create(:has_many, :products, nil, { join_table: "product_categories" }, category) + reflection.stub(:klass, product) do + assert_equal "product_categories", reflection.join_table + end + 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..a8030c2d64 --- /dev/null +++ b/activerecord/test/cases/relation/delegation_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +module ActiveRecord + module ArrayDelegationTests + ARRAY_DELEGATES = [ + :+, :-, :|, :&, :[], :shuffle, + :all?, :collect, :compact, :detect, :each, :each_cons, :each_with_index, + :exclude?, :find_all, :flat_map, :group_by, :include?, :length, + :map, :none?, :one?, :partition, :reject, :reverse, :rotate, + :sample, :second, :sort, :sort_by, :slice, :third, :index, :rindex, + :to_ary, :to_set, :to_xml, :to_yaml, :join, + :in_groups, :in_groups_of, :to_sentence, :to_formatted_s, :as_json + ] + + ARRAY_DELEGATES.each do |method| + define_method "test_delegates_#{method}_to_Array" do + assert_respond_to target, method + end + end + end + + module DeprecatedArelDelegationTests + AREL_METHODS = [ + :with, :orders, :froms, :project, :projections, :taken, :constraints, :exists, :locked, :where_sql, + :ast, :source, :join_sources, :to_dot, :create_insert, :create_true, :create_false + ] + + def test_deprecate_arel_delegation + AREL_METHODS.each do |method| + assert_deprecated { target.public_send(method) } + assert_deprecated { target.public_send(method) } + end + end + end + + class DelegationAssociationTest < ActiveRecord::TestCase + include ArrayDelegationTests + include DeprecatedArelDelegationTests + + def target + Post.new.comments + end + end + + class DelegationRelationTest < ActiveRecord::TestCase + include ArrayDelegationTests + include DeprecatedArelDelegationTests + + def target + Comment.all + end + end + + class QueryingMethodsDelegationTest < ActiveRecord::TestCase + QUERYING_METHODS = [ + :find, :take, :take!, :first, :first!, :last, :last!, :exists?, :any?, :many?, :none?, :one?, + :second, :second!, :third, :third!, :fourth, :fourth!, :fifth, :fifth!, :forty_two, :forty_two!, :third_to_last, :third_to_last!, :second_to_last, :second_to_last!, + :first_or_create, :first_or_create!, :first_or_initialize, + :find_or_create_by, :find_or_create_by!, :create_or_find_by, :create_or_find_by!, :find_or_initialize_by, + :find_by, :find_by!, + :destroy_all, :delete_all, :update_all, + :find_each, :find_in_batches, :in_batches, + :select, :group, :order, :except, :reorder, :limit, :offset, :joins, :left_joins, :left_outer_joins, :or, + :where, :rewhere, :preload, :eager_load, :includes, :from, :lock, :readonly, :extending, + :having, :create_with, :distinct, :references, :none, :unscope, :merge, + :count, :average, :minimum, :maximum, :sum, :calculate, + :pluck, :pick, :ids, + ] + + def test_delegate_querying_methods + klass = Class.new(ActiveRecord::Base) do + self.table_name = "posts" + end + + QUERYING_METHODS.each do |method| + assert_respond_to klass.all, method + assert_respond_to klass, method + end + end + end +end diff --git a/activerecord/test/cases/relation/delete_all_test.rb b/activerecord/test/cases/relation/delete_all_test.rb new file mode 100644 index 0000000000..446d7621ea --- /dev/null +++ b/activerecord/test/cases/relation/delete_all_test.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/post" +require "models/pet" +require "models/toy" + +class DeleteAllTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :posts, :pets, :toys + + def test_destroy_all + davids = Author.where(name: "David") + + # Force load + assert_equal [authors(:david)], davids.to_a + assert_predicate davids, :loaded? + + assert_difference("Author.count", -1) do + destroyed = davids.destroy_all + assert_equal [authors(:david)], destroyed + assert_predicate destroyed.first, :frozen? + end + + assert_equal [], davids.to_a + assert_predicate davids, :loaded? + end + + def test_delete_all + davids = Author.where(name: "David") + + assert_difference("Author.count", -1) { davids.delete_all } + assert_not_predicate davids, :loaded? + end + + def test_delete_all_loaded + davids = Author.where(name: "David") + + # Force load + assert_equal [authors(:david)], davids.to_a + assert_predicate davids, :loaded? + + assert_difference("Author.count", -1) { davids.delete_all } + + assert_equal [], davids.to_a + assert_predicate davids, :loaded? + end + + def test_delete_all_with_unpermitted_relation_raises_error + assert_raises(ActiveRecord::ActiveRecordError) { Author.distinct.delete_all } + assert_raises(ActiveRecord::ActiveRecordError) { Author.group(:name).delete_all } + assert_raises(ActiveRecord::ActiveRecordError) { Author.having("SUM(id) < 3").delete_all } + end + + def test_delete_all_with_joins_and_where_part_is_hash + pets = Pet.joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + def test_delete_all_with_joins_and_where_part_is_not_hash + pets = Pet.joins(:toys).where("toys.name = ?", "Bone") + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + def test_delete_all_with_left_joins + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + def test_delete_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.delete_all + end + + unless current_adapter?(:OracleAdapter) + def test_delete_all_with_order_and_limit_deletes_subset_only + author = authors(:david) + limited_posts = Post.where(author: author).order(:id).limit(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { posts(:welcome) } + assert posts(:thinking) + end + + def test_delete_all_with_order_and_limit_and_offset_deletes_subset_only + author = authors(:david) + limited_posts = Post.where(author: author).order(:id).limit(1).offset(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.delete_all + assert_raise(ActiveRecord::RecordNotFound) { posts(:thinking) } + assert posts(:welcome) + 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..224e4f39a8 --- /dev/null +++ b/activerecord/test/cases/relation/merging_test.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/comment" +require "models/developer" +require "models/computer" +require "models/post" +require "models/project" +require "models/rating" + +class RelationMergingTest < ActiveRecord::TestCase + fixtures :developers, :comments, :authors, :author_addresses, :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_predicate devs, :locked? + 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_left_outer_joins + comments = Comment.joins(:post).where(body: "Thank you for the welcome").merge(Post.left_outer_joins(:author).where(body: "Such a lovely day")) + + assert_equal 1, comments.count + end + + def test_relation_merging_with_skip_query_cache + assert_equal Post.all.merge(Post.all.skip_query_cache!).skip_query_cache_value, true + 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") + + merged = left.merge(right) + + assert_not_includes merged.to_sql, "omg" + assert_includes merged.to_sql, "wtf" + assert_includes merged.to_sql, "bbq" + 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 + + def test_merging_with_from_clause + relation = Post.all + assert_empty relation.from_clause + relation = relation.merge(Post.from("posts")) + assert_not_empty relation.from_clause + end + + def test_merging_with_from_clause_on_different_class + assert Comment.joins(:post).merge(Post.from("posts")).first + end + + def test_merging_with_order_with_binds + relation = Post.all.merge(Post.order([Arel.sql("title LIKE ?"), "%suffix"])) + assert_equal ["title LIKE '%suffix'"], relation.order_values + end + + def test_merging_with_order_without_binds + relation = Post.all.merge(Post.order(Arel.sql("title LIKE '%?'"))) + assert_equal ["title LIKE '%?'"], relation.order_values + end +end + +class MergingDifferentRelationsTest < ActiveRecord::TestCase + fixtures :posts, :authors, :author_addresses, :developers + + 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 + + test "relation merging (using a proc argument)" do + dev = Developer.where(name: "Jamis").first + + comment_1 = dev.comments.create!(body: "I'm Jamis", post: Post.first) + rating_1 = comment_1.ratings.create! + + comment_2 = dev.comments.create!(body: "I'm John", post: Post.first) + comment_2.ratings.create! + + assert_equal dev.ratings, [rating_1] + 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..f82ecd4449 --- /dev/null +++ b/activerecord/test/cases/relation/mutation_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +module ActiveRecord + class RelationMutationTest < ActiveRecord::TestCase + (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._select!(:foo).equal?(relation) + assert_equal [:foo], relation.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_predicate 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 + assert_not_called(obj, :=~) do + assert relation.order!(obj) + assert_equal [obj], relation.order_values + end + end + + test "#references!" do + assert relation.references!(:foo).equal?(relation) + assert_includes relation.references_values, "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 - [:lock, :reordering, :reverse_order, :create_with, :skip_query_cache]).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", relation.from_clause.value + end + + test "#lock!" do + assert relation.lock!("foo").equal?(relation) + assert_equal "foo", relation.lock_value + end + + test "#reorder!" do + @relation = 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_predicate 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 "merge!" do + assert relation.merge!(select: :foo).equal?(relation) + assert_equal [:foo], relation.select_values + end + + test "merge with a proc" do + assert_equal [:foo], relation.merge(-> { select(:foo) }).select_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 + end + + test "skip_query_cache!" do + relation.skip_query_cache! + assert relation.skip_query_cache_value + end + + test "skip_preloading!" do + relation.skip_preloading! + assert relation.skip_preloading_value + end + + private + def relation + @relation ||= Relation.new(FakeKlass) + end + end +end diff --git a/activerecord/test/cases/relation/or_test.rb b/activerecord/test/cases/relation/or_test.rb new file mode 100644 index 0000000000..065819e0f1 --- /dev/null +++ b/activerecord/test/cases/relation/or_test.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/categorization" +require "models/post" + +module ActiveRecord + class OrTest < ActiveRecord::TestCase + fixtures :posts + fixtures :authors, :author_addresses + + def test_or_with_relation + expected = Post.where("id = 1 or id = 2").to_a + assert_equal expected, Post.where("id = 1").or(Post.where("id = 2")).to_a + end + + def test_or_identity + expected = Post.where("id = 1").to_a + assert_equal expected, Post.where("id = 1").or(Post.where("id = 1")).to_a + end + + def test_or_with_null_left + expected = Post.where("id = 1").to_a + assert_equal expected, Post.none.or(Post.where("id = 1")).to_a + end + + def test_or_with_null_right + expected = Post.where("id = 1").to_a + assert_equal expected, Post.where("id = 1").or(Post.none).to_a + end + + def test_or_with_bind_params + assert_equal Post.find([1, 2]).sort_by(&:id), Post.where(id: 1).or(Post.where(id: 2)).sort_by(&:id) + end + + def test_or_with_null_both + expected = Post.none.to_a + assert_equal expected, Post.none.or(Post.none).to_a + end + + def test_or_without_left_where + expected = Post.all + assert_equal expected, Post.or(Post.where("id = 1")).to_a + end + + def test_or_without_right_where + expected = Post.all + assert_equal expected, Post.where("id = 1").or(Post.all).to_a + end + + def test_or_preserves_other_querying_methods + expected = Post.where("id = 1 or id = 2 or id = 3").order("body asc").to_a + partial = Post.order("body asc") + assert_equal expected, partial.where("id = 1").or(partial.where(id: [2, 3])).to_a + assert_equal expected, Post.order("body asc").where("id = 1").or(Post.order("body asc").where(id: [2, 3])).to_a + end + + def test_or_with_incompatible_relations + error = assert_raises ArgumentError do + Post.order("body asc").where("id = 1").or(Post.order("id desc").where(id: [2, 3])).to_a + end + + assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message + end + + def test_or_with_unscope_where + expected = Post.where("id = 1 or id = 2") + partial = Post.where("id = 1 and id != 2") + assert_equal expected, partial.or(partial.unscope(:where).where("id = 2")).to_a + end + + def test_or_with_unscope_where_column + expected = Post.where("id = 1 or id = 2") + partial = Post.where(id: 1).where.not(id: 2) + assert_equal expected, partial.or(partial.unscope(where: :id).where("id = 2")).to_a + end + + def test_or_with_unscope_order + expected = Post.where("id = 1 or id = 2") + assert_equal expected, Post.order("body asc").where("id = 1").unscope(:order).or(Post.where("id = 2")).to_a + end + + def test_or_with_incompatible_unscope + error = assert_raises ArgumentError do + Post.order("body asc").where("id = 1").or(Post.order("body asc").where("id = 2").unscope(:order)).to_a + end + + assert_equal "Relation passed to #or must be structurally compatible. Incompatible values: [:order]", error.message + end + + def test_or_when_grouping + groups = Post.where("id < 10").group("body").select("body, COUNT(*) AS c") + expected = groups.having("COUNT(*) > 1 OR body like 'Such%'").to_a.map { |o| [o.body, o.c] } + assert_equal expected, groups.having("COUNT(*) > 1").or(groups.having("body like 'Such%'")).to_a.map { |o| [o.body, o.c] } + end + + def test_or_with_named_scope + expected = Post.where("id = 1 or body LIKE '\%a\%'").to_a + assert_equal expected, Post.where("id = 1").or(Post.containing_the_letter_a) + end + + def test_or_inside_named_scope + expected = Post.where("body LIKE '\%a\%' OR title LIKE ?", "%'%").order("id DESC").to_a + assert_equal expected, Post.order(id: :desc).typographically_interesting + end + + def test_or_on_loaded_relation + expected = Post.where("id = 1 or id = 2").to_a + p = Post.where("id = 1") + p.load + assert_equal true, p.loaded? + assert_equal expected, p.or(Post.where("id = 2")).to_a + end + + def test_or_with_non_relation_object_raises_error + assert_raises ArgumentError do + Post.where(id: [1, 2, 3]).or(title: "Rails") + end + end + + def test_or_with_references_inequality + joined = Post.includes(:author) + actual = joined.where(authors: { id: 1 }) + .or(joined.where(title: "I don't have any comments")) + expected = Author.find(1).posts + Post.where(title: "I don't have any comments") + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + + def test_or_with_scope_on_association + author = Author.first + assert_nothing_raised do + author.top_posts.or(author.other_top_posts) + end + 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..b432330deb --- /dev/null +++ b/activerecord/test/cases/relation/predicate_builder_test.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" + +module ActiveRecord + class PredicateBuilderTest < ActiveRecord::TestCase + def test_registering_new_handlers + Topic.predicate_builder.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 + ensure + Topic.reset_column_information + end + end +end diff --git a/activerecord/test/cases/relation/record_fetch_warning_test.rb b/activerecord/test/cases/relation/record_fetch_warning_test.rb new file mode 100644 index 0000000000..22d32d75bc --- /dev/null +++ b/activerecord/test/cases/relation/record_fetch_warning_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "active_record/relation/record_fetch_warning" + +module ActiveRecord + class RecordFetchWarningTest < ActiveRecord::TestCase + fixtures :posts + + def setup + @original_logger = ActiveRecord::Base.logger + @original_warn_on_records_fetched_greater_than = ActiveRecord::Base.warn_on_records_fetched_greater_than + @log = StringIO.new + end + + def teardown + ActiveRecord::Base.logger = @original_logger + ActiveRecord::Base.warn_on_records_fetched_greater_than = @original_warn_on_records_fetched_greater_than + end + + def test_warn_on_records_fetched_greater_than_allowed_limit + ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log) + ActiveRecord::Base.logger.level = Logger::WARN + ActiveRecord::Base.warn_on_records_fetched_greater_than = 1 + + Post.all.to_a + + assert_match(/Query fetched/, @log.string) + end + + def test_does_not_warn_on_records_fetched_less_than_allowed_limit + ActiveRecord::Base.logger = ActiveSupport::Logger.new(@log) + ActiveRecord::Base.logger.level = Logger::WARN + ActiveRecord::Base.warn_on_records_fetched_greater_than = 100 + + Post.all.to_a + + assert_no_match(/Query fetched/, @log.string) + end + end +end diff --git a/activerecord/test/cases/relation/select_test.rb b/activerecord/test/cases/relation/select_test.rb new file mode 100644 index 0000000000..dec8a6925d --- /dev/null +++ b/activerecord/test/cases/relation/select_test.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" + +module ActiveRecord + class SelectTest < ActiveRecord::TestCase + fixtures :posts + + def test_select_with_nil_argument + expected = Post.select(:title).to_sql + assert_equal expected, Post.select(nil).select(:title).to_sql + end + end +end diff --git a/activerecord/test/cases/relation/update_all_test.rb b/activerecord/test/cases/relation/update_all_test.rb new file mode 100644 index 0000000000..bb6912148c --- /dev/null +++ b/activerecord/test/cases/relation/update_all_test.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/category" +require "models/comment" +require "models/computer" +require "models/developer" +require "models/post" +require "models/person" +require "models/pet" +require "models/toy" +require "models/topic" +require "models/tag" +require "models/tagging" +require "models/warehouse_thing" + +class UpdateAllTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :comments, :developers, :posts, :people, :pets, :toys, :tags, :taggings, "warehouse-things" + + class TopicWithCallbacks < ActiveRecord::Base + self.table_name = :topics + cattr_accessor :topic_count + before_update { |topic| topic.author_name = "David" if topic.author_name.blank? } + after_update { |topic| topic.class.topic_count = topic.class.count } + end + + def test_update_all_with_scope + tag = Tag.first + Post.tagged_with(tag.id).update_all(title: "rofl") + posts = Post.tagged_with(tag.id).all.to_a + assert_operator posts.length, :>, 0 + posts.each { |post| assert_equal "rofl", post.title } + 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_update_all_with_blank_argument + assert_raises(ArgumentError) { Comment.update_all({}) } + end + + def test_update_all_with_joins + pets = Pet.joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") + end + + def test_update_all_with_left_joins + pets = Pet.left_joins(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") + end + + def test_update_all_with_includes + pets = Pet.includes(:toys).where(toys: { name: "Bone" }) + + assert_equal true, pets.exists? + assert_equal pets.count, pets.update_all(name: "Bob") + 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) + assert_equal posts(:thinking), comments(:greetings).post + 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_update_counters_with_joins + assert_nil pets(:parrot).integer + + Pet.joins(:toys).where(toys: { name: "Bone" }).update_counters(integer: 1) + + assert_equal 1, pets(:parrot).reload.integer + end + + def test_touch_all_updates_records_timestamps + david = developers(:david) + david_previously_updated_at = david.updated_at + jamis = developers(:jamis) + jamis_previously_updated_at = jamis.updated_at + Developer.where(name: "David").touch_all + + assert_not_equal david_previously_updated_at, david.reload.updated_at + assert_equal jamis_previously_updated_at, jamis.reload.updated_at + end + + def test_touch_all_with_custom_timestamp + developer = developers(:david) + previously_created_at = developer.created_at + previously_updated_at = developer.updated_at + Developer.where(name: "David").touch_all(:created_at) + developer.reload + + assert_not_equal previously_created_at, developer.created_at + assert_not_equal previously_updated_at, developer.updated_at + end + + def test_touch_all_with_given_time + developer = developers(:david) + previously_created_at = developer.created_at + previously_updated_at = developer.updated_at + new_time = Time.utc(2015, 2, 16, 4, 54, 0) + Developer.where(name: "David").touch_all(:created_at, time: new_time) + developer.reload + + assert_not_equal previously_created_at, developer.created_at + assert_not_equal previously_updated_at, developer.updated_at + assert_equal new_time, developer.created_at + assert_equal new_time, developer.updated_at + end + + def test_touch_all_updates_locking_column + person = people(:david) + + assert_difference -> { person.reload.lock_version }, +1 do + Person.where(first_name: "David").touch_all + end + end + + def test_update_on_relation + topic1 = TopicWithCallbacks.create! title: "arel", author_name: nil + topic2 = TopicWithCallbacks.create! title: "activerecord", author_name: nil + topics = TopicWithCallbacks.where(id: [topic1.id, topic2.id]) + topics.update(title: "adequaterecord") + + assert_equal TopicWithCallbacks.count, TopicWithCallbacks.topic_count + + assert_equal "adequaterecord", topic1.reload.title + assert_equal "adequaterecord", topic2.reload.title + # Testing that the before_update callbacks have run + assert_equal "David", topic1.reload.author_name + assert_equal "David", topic2.reload.author_name + end + + def test_update_with_ids_on_relation + topic1 = TopicWithCallbacks.create!(title: "arel", author_name: nil) + topic2 = TopicWithCallbacks.create!(title: "activerecord", author_name: nil) + topics = TopicWithCallbacks.none + topics.update( + [topic1.id, topic2.id], + [{ title: "adequaterecord" }, { title: "adequaterecord" }] + ) + + assert_equal TopicWithCallbacks.count, TopicWithCallbacks.topic_count + + assert_equal "adequaterecord", topic1.reload.title + assert_equal "adequaterecord", topic2.reload.title + # Testing that the before_update callbacks have run + assert_equal "David", topic1.reload.author_name + assert_equal "David", topic2.reload.author_name + end + + def test_update_on_relation_passing_active_record_object_is_not_permitted + topic = Topic.create!(title: "Foo", author_name: nil) + assert_raises(ArgumentError) do + Topic.where(id: topic.id).update(topic, title: "Bar") + end + end + + # 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| + Author.order(order).update_all("id = id + 1") + rescue ActiveRecord::ActiveRecordError + false + end + + if test_update_with_order_succeeds.call("id DESC") + # test that this wasn't a fluke and using an incorrect order results in an exception + assert_not test_update_with_order_succeeds.call("id ASC") + 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) + limited_posts = author.posts_sorted_by_id_limited + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.update_all([ "body = ?", "bulk update!" ]) + assert_equal "bulk update!", posts(:welcome).body + assert_not_equal "bulk update!", posts(:thinking).body + end + + def test_update_all_with_order_and_limit_and_offset_updates_subset_only + author = authors(:david) + limited_posts = author.posts_sorted_by_id_limited.offset(1) + assert_equal 1, limited_posts.size + assert_equal 2, limited_posts.limit(2).size + assert_equal 1, limited_posts.update_all([ "body = ?", "bulk update!" ]) + assert_equal "bulk update!", posts(:thinking).body + assert_not_equal "bulk update!", posts(:welcome).body + 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..a68eb2b446 --- /dev/null +++ b/activerecord/test/cases/relation/where_chain_test.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +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_inverts_where_clause + relation = Post.where.not(title: "hello") + expected_where_clause = Post.where(title: "hello").where_clause.invert + + assert_equal expected_where_clause, relation.where_clause + end + + def test_not_with_nil + assert_raise ArgumentError do + Post.where.not(nil) + end + end + + def test_association_not_eq + expected = Comment.arel_table[@name].not_eq(Arel::Nodes::BindParam.new(1)) + relation = Post.joins(:comments).where.not(comments: { title: "hello" }) + assert_equal(expected.to_sql, relation.where_clause.ast.to_sql) + end + + def test_not_eq_with_preceding_where + relation = Post.where(title: "hello").where.not(title: "world") + expected_where_clause = + Post.where(title: "hello").where_clause + + Post.where(title: "world").where_clause.invert + + assert_equal expected_where_clause, relation.where_clause + end + + def test_not_eq_with_succeeding_where + relation = Post.where.not(title: "hello").where(title: "world") + expected_where_clause = + Post.where(title: "hello").where_clause.invert + + Post.where(title: "world").where_clause + + assert_equal expected_where_clause, relation.where_clause + end + + def test_chaining_multiple + relation = Post.where.not(author_id: [1, 2]).where.not(title: "ruby on rails") + expected_where_clause = + Post.where(author_id: [1, 2]).where_clause.invert + + Post.where(title: "ruby on rails").where_clause.invert + + assert_equal expected_where_clause, relation.where_clause + end + + def test_rewhere_with_one_condition + relation = Post.where(title: "hello").where(title: "world").rewhere(title: "alone") + expected = Post.where(title: "alone") + + assert_equal expected.where_clause, relation.where_clause + end + + def test_rewhere_with_multiple_overwriting_conditions + relation = Post.where(title: "hello").where(body: "world").rewhere(title: "alone", body: "again") + expected = Post.where(title: "alone", body: "again") + + assert_equal expected.where_clause, relation.where_clause + end + + def test_rewhere_with_one_overwriting_condition_and_one_unrelated + relation = Post.where(title: "hello").where(body: "world").rewhere(title: "alone") + expected = Post.where(body: "world", title: "alone") + + assert_equal expected.where_clause, relation.where_clause + end + + def test_rewhere_with_range + relation = Post.where(comments_count: 1..3).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + + def test_rewhere_with_infinite_upper_bound_range + relation = Post.where(comments_count: 1..Float::INFINITY).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + + def test_rewhere_with_infinite_lower_bound_range + relation = Post.where(comments_count: -Float::INFINITY..1).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + + def test_rewhere_with_infinite_range + relation = Post.where(comments_count: -Float::INFINITY..Float::INFINITY).rewhere(comments_count: 3..5) + + assert_equal Post.where(comments_count: 3..5), relation + end + end +end diff --git a/activerecord/test/cases/relation/where_clause_test.rb b/activerecord/test/cases/relation/where_clause_test.rb new file mode 100644 index 0000000000..0b06cec40b --- /dev/null +++ b/activerecord/test/cases/relation/where_clause_test.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ActiveRecord::Relation + class WhereClauseTest < ActiveRecord::TestCase + test "+ combines two where clauses" do + first_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + second_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + combined = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + + assert_equal combined, first_clause + second_clause + end + + test "+ is associative, but not commutative" do + a = WhereClause.new(["a"]) + b = WhereClause.new(["b"]) + c = WhereClause.new(["c"]) + + assert_equal a + (b + c), (a + b) + c + assert_not_equal a + b, b + a + end + + test "an empty where clause is the identity value for +" do + clause = WhereClause.new([table["id"].eq(bind_param(1))]) + + assert_equal clause, clause + WhereClause.empty + end + + test "merge combines two where clauses" do + a = WhereClause.new([table["id"].eq(1)]) + b = WhereClause.new([table["name"].eq("Sean")]) + expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")]) + + assert_equal expected, a.merge(b) + end + + test "merge keeps the right side, when two equality clauses reference the same column" do + a = WhereClause.new([table["id"].eq(1), table["name"].eq("Sean")]) + b = WhereClause.new([table["name"].eq("Jim")]) + expected = WhereClause.new([table["id"].eq(1), table["name"].eq("Jim")]) + + assert_equal expected, a.merge(b) + end + + test "merge removes bind parameters matching overlapping equality clauses" do + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + b = WhereClause.new( + [table["name"].eq(bind_param("Jim"))], + ) + expected = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Jim"))], + ) + + assert_equal expected, a.merge(b) + end + + test "merge allows for columns with the same name from different tables" do + table2 = Arel::Table.new("table2") + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table2["id"].eq(bind_param(2))], + ) + b = WhereClause.new( + [table["id"].eq(bind_param(3))], + ) + expected = WhereClause.new( + [table2["id"].eq(bind_param(2)), table["id"].eq(bind_param(3))], + ) + + assert_equal expected, a.merge(b) + end + + test "a clause knows if it is empty" do + assert_empty WhereClause.empty + assert_not_empty WhereClause.new(["anything"]) + end + + test "invert cannot handle nil" do + where_clause = WhereClause.new([nil]) + + assert_raises ArgumentError do + where_clause.invert + end + end + + test "invert replaces each part of the predicate with its inverse" do + random_object = Object.new + original = WhereClause.new([ + table["id"].in([1, 2, 3]), + table["id"].eq(1), + table["id"].is_not_distinct_from(1), + table["id"].is_distinct_from(2), + "sql literal", + random_object + ]) + expected = WhereClause.new([ + table["id"].not_in([1, 2, 3]), + table["id"].not_eq(1), + table["id"].is_distinct_from(1), + table["id"].is_not_distinct_from(2), + Arel::Nodes::Not.new(Arel::Nodes::SqlLiteral.new("sql literal")), + Arel::Nodes::Not.new(random_object) + ]) + + assert_equal expected, original.invert + end + + test "except removes binary predicates referencing a given column" do + where_clause = WhereClause.new([ + table["id"].in([1, 2, 3]), + table["name"].eq(bind_param("Sean")), + table["age"].gteq(bind_param(30)), + ]) + expected = WhereClause.new([table["age"].gteq(bind_param(30))]) + + assert_equal expected, where_clause.except("id", "name") + end + + test "except jumps over unhandled binds (like with OR) correctly" do + wcs = (0..9).map do |i| + WhereClause.new([table["id#{i}"].eq(bind_param(i))]) + end + + wc = wcs[0] + wcs[1] + wcs[2].or(wcs[3]) + wcs[4] + wcs[5] + wcs[6].or(wcs[7]) + wcs[8] + wcs[9] + + expected = wcs[0] + wcs[2].or(wcs[3]) + wcs[5] + wcs[6].or(wcs[7]) + wcs[9] + actual = wc.except("id1", "id2", "id4", "id7", "id8") + + assert_equal expected, actual + end + + test "ast groups its predicates with AND" do + predicates = [ + table["id"].in([1, 2, 3]), + table["name"].eq(bind_param(nil)), + ] + where_clause = WhereClause.new(predicates) + expected = Arel::Nodes::And.new(predicates) + + assert_equal expected, where_clause.ast + end + + test "ast wraps any SQL literals in parenthesis" do + random_object = Object.new + where_clause = WhereClause.new([ + table["id"].in([1, 2, 3]), + "foo = bar", + random_object, + ]) + expected = Arel::Nodes::And.new([ + table["id"].in([1, 2, 3]), + Arel::Nodes::Grouping.new(Arel.sql("foo = bar")), + random_object, + ]) + + assert_equal expected, where_clause.ast + end + + test "ast removes any empty strings" do + where_clause = WhereClause.new([table["id"].in([1, 2, 3])]) + where_clause_with_empty = WhereClause.new([table["id"].in([1, 2, 3]), ""]) + + assert_equal where_clause.ast, where_clause_with_empty.ast + end + + test "or joins the two clauses using OR" do + where_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + other_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + expected_ast = + Arel::Nodes::Grouping.new( + Arel::Nodes::Or.new(table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))) + ) + + assert_equal expected_ast.to_sql, where_clause.or(other_clause).ast.to_sql + end + + test "or returns an empty where clause when either side is empty" do + where_clause = WhereClause.new([table["id"].eq(bind_param(1))]) + + assert_equal WhereClause.empty, where_clause.or(WhereClause.empty) + assert_equal WhereClause.empty, WhereClause.empty.or(where_clause) + end + + test "or places common conditions before the OR" do + a = WhereClause.new( + [table["id"].eq(bind_param(1)), table["name"].eq(bind_param("Sean"))], + ) + b = WhereClause.new( + [table["id"].eq(bind_param(1)), table["hair_color"].eq(bind_param("black"))], + ) + + common = WhereClause.new( + [table["id"].eq(bind_param(1))], + ) + + or_clause = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))])) + + assert_equal common + or_clause, a.or(b) + end + + test "or can detect identical or as being a common condition" do + common_or = WhereClause.new([table["name"].eq(bind_param("Sean"))]) + .or(WhereClause.new([table["hair_color"].eq(bind_param("black"))])) + + a = common_or + WhereClause.new([table["id"].eq(bind_param(1))]) + b = common_or + WhereClause.new([table["foo"].eq(bind_param("bar"))]) + + new_or = WhereClause.new([table["id"].eq(bind_param(1))]) + .or(WhereClause.new([table["foo"].eq(bind_param("bar"))])) + + assert_equal common_or + new_or, a.or(b) + end + + test "or will use only common conditions if one side only has common conditions" do + only_common = WhereClause.new([ + table["id"].eq(bind_param(1)), + "foo = bar", + ]) + + common_with_extra = WhereClause.new([ + table["id"].eq(bind_param(1)), + "foo = bar", + table["extra"].eq(bind_param("pluto")), + ]) + + assert_equal only_common, only_common.or(common_with_extra) + assert_equal only_common, common_with_extra.or(only_common) + end + + private + + def table + Arel::Table.new("table") + end + + def bind_param(value) + Arel::Nodes::BindParam.new(value) + 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..99797528b2 --- /dev/null +++ b/activerecord/test/cases/relation/where_test.rb @@ -0,0 +1,366 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/author" +require "models/binary" +require "models/cake_designer" +require "models/car" +require "models/chef" +require "models/post" +require "models/comment" +require "models/edge" +require "models/essay" +require "models/price_estimate" +require "models/topic" +require "models/treasure" +require "models/vertex" + +module ActiveRecord + class WhereTest < ActiveRecord::TestCase + fixtures :posts, :edges, :authors, :author_addresses, :binaries, :essays, :cars, :treasures, :price_estimates, :topics + + 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_where_copies_bind_params_in_the_right_order + author = authors(:david) + posts = author.posts.where.not(id: 1) + joined = Post.where(id: posts, title: posts.first.title) + + assert_equal joined, [posts.first] + end + + def test_where_copies_arel_bind_params + chef = Chef.create! + CakeDesigner.create!(chef: chef) + + cake_designers = CakeDesigner.joins(:chef).where(chefs: { id: chef.id }) + chefs = Chef.where(employable: cake_designers) + + assert_equal [chef], chefs.to_a + end + + def test_where_with_casted_value_is_nil + assert_equal 4, Topic.where(last_read: "").count + 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_belongs_to_nested_where_with_relation + author = authors(:david) + + expected = Author.where(id: author).joins(:posts) + actual = Author.where(posts: { author_id: Author.where(id: author.id) }).joins(:posts) + + assert_equal expected.to_a, actual.to_a + 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_shallow_where_not + treasure = treasures(:sapphire) + + expected = [price_estimates(:diamond), price_estimates(:honda)] + actual = PriceEstimate.where.not(estimate_of: treasure) + + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + 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_array_where_not + treasure = treasures(:diamond) + car = cars(:honda) + + expected = [price_estimates(:sapphire_1), price_estimates(:sapphire_2)] + actual = PriceEstimate.where.not(estimate_of: [treasure, car]) + + assert_equal expected.sort_by(&:id), actual.sort_by(&:id) + end + + def test_polymorphic_array_where_multiple_types + treasure_1 = treasures(:diamond) + treasure_2 = treasures(:sapphire) + car = cars(:honda) + + expected = [price_estimates(:diamond), price_estimates(:sapphire_1), price_estimates(:sapphire_2), price_estimates(:honda)].sort + actual = PriceEstimate.where(estimate_of: [treasure_1, treasure_2, car]).to_a.sort + + assert_equal expected, actual + 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_nothing_raised 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(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 + + def test_where_on_association_with_custom_primary_key + author = authors(:david) + essay = Essay.where(writer: author).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_on_association_with_custom_primary_key_with_relation + author = authors(:david) + essay = Essay.where(writer: Author.where(id: author.id)).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_on_association_with_relation_performs_subselect_not_two_queries + author = authors(:david) + + assert_queries(1) do + Essay.where(writer: Author.where(id: author.id)).to_a + end + end + + def test_where_on_association_with_custom_primary_key_with_array_of_base + author = authors(:david) + essay = Essay.where(writer: [author]).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_on_association_with_custom_primary_key_with_array_of_ids + essay = Essay.where(writer: ["David"]).first + + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_with_relation_on_has_many_association + essay = essays(:david_modest_proposal) + author = Author.where(essays: Essay.where(id: essay.id)).first + + assert_equal authors(:david), author + end + + def test_where_with_relation_on_has_one_association + author = authors(:david) + author_address = AuthorAddress.where(author: Author.where(id: author.id)).first + assert_equal author_addresses(:david_address), author_address + end + + + def test_where_on_association_with_select_relation + essay = Essay.where(author: Author.where(name: "David").select(:name)).take + assert_equal essays(:david_modest_proposal), essay + end + + def test_where_with_strong_parameters + protected_params = Class.new do + attr_reader :permitted + alias :permitted? :permitted + + def initialize(parameters) + @parameters = parameters + @permitted = false + end + + def to_h + @parameters + end + + def permit! + @permitted = true + self + end + end + + author = authors(:david) + params = protected_params.new(name: author.name) + assert_raises(ActiveModel::ForbiddenAttributesError) { Author.where(params) } + assert_equal author, Author.where(params.permit!).first + end + + def test_where_with_unsupported_arguments + assert_raises(ArgumentError) { Author.where(42) } + 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..68161f6a84 --- /dev/null +++ b/activerecord/test/cases/relation_test.rb @@ -0,0 +1,372 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" +require "models/author" +require "models/rating" +require "models/categorization" + +module ActiveRecord + class RelationTest < ActiveRecord::TestCase + fixtures :posts, :comments, :authors, :author_addresses, :ratings, :categorizations + + def test_construction + relation = Relation.new(FakeKlass, table: :b) + assert_equal FakeKlass, relation.klass + assert_equal :b, relation.table + assert_not relation.loaded, "relation is not loaded" + end + + def test_responds_to_model_and_returns_klass + relation = Relation.new(FakeKlass) + assert_equal FakeKlass, relation.model + end + + def test_initialize_single_values + relation = Relation.new(FakeKlass) + (Relation::SINGLE_VALUE_METHODS - [:create_with]).each do |method| + assert_nil relation.send("#{method}_value"), method.to_s + end + value = relation.create_with_value + assert_equal({}, value) + assert_predicate value, :frozen? + end + + def test_multi_value_initialize + relation = Relation.new(FakeKlass) + Relation::MULTI_VALUE_METHODS.each do |method| + values = relation.send("#{method}_values") + assert_equal [], values, method.to_s + assert_predicate values, :frozen?, method.to_s + end + end + + def test_extensions + relation = Relation.new(FakeKlass) + assert_equal [], relation.extensions + end + + def test_empty_where_values_hash + relation = Relation.new(FakeKlass) + assert_equal({}, relation.where_values_hash) + end + + def test_has_values + relation = Relation.new(Post) + relation.where!(id: 10) + assert_equal({ "id" => 10 }, relation.where_values_hash) + end + + def test_values_wrong_table + relation = Relation.new(Post) + 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) + left = relation.table[:id].eq(10) + right = relation.table[:id].eq(10) + combine = left.or(right) + relation.where! combine + assert_equal({}, relation.where_values_hash) + end + + def test_scope_for_create + relation = Relation.new(FakeKlass) + assert_equal({}, relation.scope_for_create) + end + + def test_create_with_value + relation = Relation.new(Post) + relation.create_with_value = { hello: "world" } + assert_equal({ "hello" => "world" }, relation.scope_for_create) + end + + def test_create_with_value_with_wheres + relation = Relation.new(Post) + assert_equal({}, relation.scope_for_create) + + relation.where!(id: 10) + assert_equal({ "id" => 10 }, relation.scope_for_create) + + relation.create_with_value = { hello: "world" } + assert_equal({ "hello" => "world", "id" => 10 }, relation.scope_for_create) + end + + def test_empty_scope + relation = Relation.new(Post) + assert_predicate relation, :empty_scope? + + relation.merge!(relation) + assert_predicate relation, :empty_scope? + 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) + assert_not_predicate relation, :eager_loading? + end + + def test_eager_load_values + relation = Relation.new(FakeKlass) + relation.eager_load! :b + assert_predicate relation, :eager_loading? + end + + def test_references_values + relation = Relation.new(FakeKlass) + 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) + relation = relation.references(:foo).references(:foo) + assert_equal ["foo"], relation.references_values + end + + test "merging a hash into a relation" do + relation = Relation.new(Post) + relation = relation.merge where: { name: :lol }, readonly: true + + assert_equal({ "name" => :lol }, relation.where_clause.to_h) + assert_equal true, relation.readonly_value + end + + test "merging an empty hash into a relation" do + assert_equal Relation::WhereClause.empty, Relation.new(FakeKlass).merge({}).where_clause + end + + test "merging a hash with unknown keys raises" do + assert_raises(ArgumentError) { Relation::HashMerger.new(nil, omg: "lol") } + end + + test "merging nil or false raises" do + relation = Relation.new(FakeKlass) + + e = assert_raises(ArgumentError) do + relation = relation.merge nil + end + + assert_equal "invalid argument: nil.", e.message + + e = assert_raises(ArgumentError) do + relation = relation.merge false + end + + assert_equal "invalid argument: false.", e.message + end + + test "#values returns a dup of the values" do + relation = Relation.new(Post).where!(name: :foo) + values = relation.values + + values[:where] = nil + assert_not_nil relation.where_clause + end + + test "relations can be created with a values hash" do + relation = Relation.new(FakeKlass, values: { select: [:foo] }) + assert_equal [:foo], relation.select_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) + relation.merge!(where: ["foo = ?", "bar"]) + assert_equal Relation::WhereClause.new(["foo = bar"]), relation.where_clause + end + + def test_merging_readonly_false + relation = Relation.new(FakeKlass) + 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({ 4 => 2 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count) + end + + def test_relation_merging_with_merged_symbol_joins_keeps_inner_joins + queries = capture_sql { Author.joins(:posts).merge(Post.joins(:comments)).to_a } + + nb_inner_join = queries.sum { |sql| sql.scan(/INNER\s+JOIN/i).size } + assert_equal 2, nb_inner_join, "Wrong amount of INNER JOIN in query" + assert queries.none? { |sql| /LEFT\s+(OUTER)?\s+JOIN/i.match?(sql) }, "Shouldn't have any LEFT JOIN in query" + end + + def test_relation_merging_with_merged_symbol_joins_has_correct_size_and_count + # Has one entry per comment + merged_authors_with_commented_posts_relation = Author.joins(:posts).merge(Post.joins(:comments)) + + post_ids_with_author = Post.joins(:author).pluck(:id) + manual_comments_on_post_that_have_author = Comment.where(post_id: post_ids_with_author).pluck(:id) + + assert_equal manual_comments_on_post_that_have_author.size, merged_authors_with_commented_posts_relation.count + assert_equal manual_comments_on_post_that_have_author.size, merged_authors_with_commented_posts_relation.to_a.size + end + + def test_relation_merging_with_merged_symbol_joins_is_aliased + categorizations_with_authors = Categorization.joins(:author) + queries = capture_sql { Post.joins(:author, :categorizations).merge(Author.select(:id)).merge(categorizations_with_authors).to_a } + + nb_inner_join = queries.sum { |sql| sql.scan(/INNER\s+JOIN/i).size } + assert_equal 3, nb_inner_join, "Wrong amount of INNER JOIN in query" + + # using `\W` as the column separator + assert queries.any? { |sql| %r[INNER\s+JOIN\s+#{Regexp.escape(Author.quoted_table_name)}\s+\Wauthors_categorizations\W]i.match?(sql) }, "Should be aliasing the child INNER JOINs in query" + end + + def test_relation_with_merged_joins_aliased_works + categorizations_with_authors = Categorization.joins(:author) + posts_with_joins_and_merges = Post.joins(:author, :categorizations) + .merge(Author.select(:id)).merge(categorizations_with_authors) + + author_with_posts = Author.joins(:posts).ids + categorizations_with_author = Categorization.joins(:author).ids + posts_with_author_and_categorizations = Post.joins(:categorizations).where(author_id: author_with_posts, categorizations: { id: categorizations_with_author }).ids + + assert_equal posts_with_author_and_categorizations.size, posts_with_joins_and_merges.count + assert_equal posts_with_author_and_categorizations.size, posts_with_joins_and_merges.to_a.size + 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_merge_raises_with_invalid_argument + assert_raises ArgumentError do + relation = Relation.new(FakeKlass) + relation.merge(true) + end + end + + def test_respond_to_for_non_selected_element + post = Post.select(:title).first + assert_not_respond_to post, :body, "post should not respond_to?(:body) since invoking it raises exception" + + silence_warnings { post = Post.select("'title' as post_title").first } + assert_not_respond_to post, :title, "post should not respond_to?(:body) since invoking it raises exception" + end + + def test_select_quotes_when_using_from_clause + skip_if_sqlite3_version_includes_quoting_bug + quoted_join = ActiveRecord::Base.connection.quote_table_name("join") + selected = Post.select(:join).from(Post.select("id as #{quoted_join}")).map(&:join) + assert_equal Post.pluck(:id), selected + end + + def test_selecting_aliased_attribute_quotes_column_name_when_from_is_used + skip_if_sqlite3_version_includes_quoting_bug + klass = Class.new(ActiveRecord::Base) do + self.table_name = :test_with_keyword_column_name + alias_attribute :description, :desc + end + klass.create!(description: "foo") + + assert_equal ["foo"], klass.select(:description).from(klass.all).map(&:desc) + 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({ 2 => 1, 4 => 3, 5 => 1 }, authors(:david).posts.merge(posts_with_special_comments_with_ratings).count) + end + + def test_relation_merging_keeps_joining_order + authors = Author.where(id: 1) + posts = Post.joins(:author).merge(authors) + comments = Comment.joins(:post).merge(posts) + ratings = Rating.joins(:comment).merge(comments) + + assert_equal 3, ratings.count + end + + class EnsureRoundTripTypeCasting < ActiveRecord::Type::Value + def type + :string + end + + def cast(value) + raise value unless value == "value from user" + "cast value" + end + + def deserialize(value) + raise value unless value == "type cast for database" + "type cast from database" + end + + def serialize(value) + raise value unless value == "cast value" + "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 + + def test_skip_preloading_after_arel_has_been_generated + assert_nothing_raised do + relation = Comment.all + relation.arel + relation.skip_preloading! + end + end + + private + + def skip_if_sqlite3_version_includes_quoting_bug + if sqlite3_version_includes_quoting_bug? + skip <<-ERROR.squish + You are using an outdated version of SQLite3 which has a bug in + quoted column names. Please update SQLite3 and rebuild the sqlite3 + ruby gem + ERROR + end + end + + def sqlite3_version_includes_quoting_bug? + if current_adapter?(:SQLite3Adapter) + selected_quoted_column_names = ActiveRecord::Base.connection.exec_query( + 'SELECT "join" FROM (SELECT id AS "join" FROM posts) subquery' + ).columns + ["join"] != selected_quoted_column_names + end + 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..756eeca35f --- /dev/null +++ b/activerecord/test/cases/relations_test.rb @@ -0,0 +1,1931 @@ +# frozen_string_literal: true + +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/project" +require "models/person" +require "models/computer" +require "models/reply" +require "models/company" +require "models/bird" +require "models/car" +require "models/engine" +require "models/tyre" +require "models/minivan" +require "models/possession" +require "models/reader" +require "models/category" +require "models/categorization" +require "models/edge" +require "models/subscriber" + +class RelationTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :topics, :entrants, :developers, :people, :companies, :developers_projects, :accounts, :categories, :categorizations, :categories_posts, :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_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_respond_to x.klass, :find_by_id + 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_predicate 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_not_predicate topics, :loaded? + end + + def test_loaded_first + topics = Topic.all.order("id ASC") + topics.load # force load + + assert_no_queries do + assert_equal "The First Topic", topics.first.title + end + + assert_predicate topics, :loaded? + end + + def test_loaded_first_with_limit + topics = Topic.all.order("id ASC") + topics.load # force load + + assert_no_queries do + assert_equal ["The First Topic", + "The Second Topic of the day"], topics.first(2).map(&:title) + end + + assert_predicate topics, :loaded? + end + + def test_first_get_more_than_available + topics = Topic.all.order("id ASC") + unloaded_first = topics.first(10) + topics.load # force load + + assert_no_queries do + loaded_first = topics.first(10) + assert_equal unloaded_first, loaded_first + end + end + + def test_reload + topics = Topic.all + + assert_queries(1) do + 2.times { topics.to_a } + end + + assert_predicate 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_predicate 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_select_with_subquery_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count, type") + subquery = Comment.from(relation).select("type", "post_count") + assert_equal(relation.map(&:post_count).sort, subquery.map(&:post_count).sort) + end + + def test_group_with_subquery_in_from_does_not_use_original_table_name + relation = Comment.group(:type).select("COUNT(post_id) AS post_count,type") + subquery = Comment.from(relation).group("type").average("post_count") + assert_equal(relation.map(&:post_count).sort, subquery.values.sort) + end + + def test_finding_with_subquery_with_eager_loading_in_from + relation = Comment.includes(:post).where("posts.type": "Post") + 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_with_eager_loading_in_where + relation = Comment.includes(:post).where("posts.type": "Post") + assert_equal relation.sort_by(&:id), Comment.where(id: relation).sort_by(&:id) + 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_arel_assoc_order + topics = Topic.order(Arel.sql("id") => :desc) + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_finding_with_reversed_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_finding_with_reversed_arel_assoc_order + topics = Topic.order(Arel.sql("id") => :asc).reverse_order + assert_equal 5, topics.to_a.size + assert_equal topics(:fifth).title, topics.first.title + end + + def test_reverse_order_with_function + topics = Topic.order(Arel.sql("length(title)")).reverse_order + assert_equal topics(:second).title, topics.first.title + end + + def test_reverse_arel_assoc_order_with_function + topics = Topic.order(Arel.sql("length(title)") => :asc).reverse_order + assert_equal topics(:second).title, topics.first.title + end + + def test_reverse_order_with_function_other_predicates + topics = Topic.order(Arel.sql("author_name, length(title), id")).reverse_order + assert_equal topics(:second).title, topics.first.title + topics = Topic.order(Arel.sql("length(author_name), id, length(title)")).reverse_order + assert_equal topics(:fifth).title, topics.first.title + end + + def test_reverse_order_with_multiargument_function + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("concat(author_name, title)")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("concat(lower(author_name), title)")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("concat(author_name, lower(title))")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("concat(lower(author_name), title, length(title)")).reverse_order + end + end + + def test_reverse_arel_assoc_order_with_multiargument_function + assert_nothing_raised do + Topic.order(Arel.sql("REPLACE(title, '', '')") => :asc).reverse_order + end + end + + def test_reverse_order_with_nulls_first_or_last + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("title NULLS FIRST")).reverse_order + end + assert_raises(ActiveRecord::IrreversibleOrderError) do + Topic.order(Arel.sql("title nulls last")).reverse_order + end + end + + def test_default_reverse_order_on_table_without_primary_key + assert_raises(ActiveRecord::IrreversibleOrderError) do + Edge.all.reverse_order + end + 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(&: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", Arel.sql("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(Arel.sql("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(Arel.sql("REPLACE('abc', taggings.taggable_type, taggings.taggable_type)")).to_a + assert_equal 3, tags.length + end + + def test_finding_with_sanitized_order + query = Tag.order([Arel.sql("field(id, ?)"), [1, 3, 2]]).to_sql + assert_match(/field\(id, 1,3,2\)/, query) + + query = Tag.order([Arel.sql("field(id, ?)"), []]).to_sql + assert_match(/field\(id, NULL\)/, query) + + query = Tag.order([Arel.sql("field(id, ?)"), nil]).to_sql + assert_match(/field\(id, NULL\)/, query) + 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_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(&:name) + assert_includes developer_names, "David" + assert_includes developer_names, "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_respond_to_delegates_to_arel + 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 + 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 + end + end + + def test_respond_to_class_methods_and_scopes + assert_respond_to Topic.all, :by_lifo + end + + def test_find_with_readonly_option + Developer.all.each { |d| assert_not 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_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_preloading_with_associations_and_merges + post = Post.create! title: "Uhuu", body: "body" + reader = Reader.create! post_id: post.id, person_id: 1 + comment = Comment.create! post_id: post.id, body: "body" + + assert_not_respond_to comment, :readers + + post_rel = Post.preload(:readers).joins(:readers).where(title: "Uhuu") + result_comment = Comment.joins(:post).merge(post_rel).to_a.first + assert_equal comment, result_comment + + assert_no_queries do + assert_equal post, result_comment.post + assert_equal [reader], result_comment.post.readers.to_a + end + + post_rel = Post.includes(:readers).where(title: "Uhuu") + result_comment = Comment.joins(:post).merge(post_rel).first + assert_equal comment, result_comment + + assert_no_queries do + assert_equal post, result_comment.post + assert_equal [reader], result_comment.post.readers.to_a + end + end + + def test_preloading_with_associations_default_scopes_and_merges + post = Post.create! title: "Uhuu", body: "body" + reader = Reader.create! post_id: post.id, person_id: 1 + + post_rel = PostWithPreloadDefaultScope.preload(:readers).joins(:readers).where(title: "Uhuu") + result_post = PostWithPreloadDefaultScope.all.merge(post_rel).to_a.first + + assert_no_queries do + assert_equal [reader], result_post.readers.to_a + end + + post_rel = PostWithIncludesDefaultScope.includes(:readers).where(title: "Uhuu") + result_post = PostWithIncludesDefaultScope.all.merge(post_rel).to_a.first + + assert_no_queries do + assert_equal [reader], result_post.readers.to_a + end + 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_includes post.comments, comments(:greetings) + + post = Post.where("posts.title = 'Welcome to the weblog'").preload(:comments).first + assert_equal 2, post.comments.size + assert_includes post.comments, 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.uniq.sort_by(&: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_predicate 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 + + 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) + 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) + 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_last + authors = Author.all + assert_equal authors(:bob), authors.last + end + + def test_select_with_aggregates + posts = Post.select(:title, :body) + + assert_equal 11, posts.count(:all) + assert_equal 11, posts.size + assert_predicate posts, :any? + assert_predicate posts, :many? + assert_not_empty posts + 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 3, posts.where("comments_count > 1").count + assert_equal 6, posts.where(comments_count: 0).count + end + + def test_count_with_block + posts = Post.all + assert_equal 8, posts.count { |p| p.comments_count.even? } + 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_empty author.posts.where(author_id: another_author.id) + end + + def test_count_with_distinct + posts = Post.all + + assert_equal 4, posts.distinct(true).count(:comments_count) + assert_equal 11, posts.distinct(false).count(:comments_count) + + assert_equal 4, posts.distinct(true).select(:comments_count).count + assert_equal 11, posts.distinct(false).select(:comments_count).count + end + + def test_size_with_distinct + posts = Post.distinct.select(:author_id, :comments_count) + assert_queries(1) { assert_equal 8, posts.size } + assert_queries(1) { assert_equal 8, posts.load.size } + end + + def test_size_with_eager_loading_and_custom_order + posts = Post.includes(:comments).order("comments.id") + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + end + + def test_size_with_eager_loading_and_custom_order_and_distinct + posts = Post.includes(:comments).order("comments.id").distinct + assert_queries(1) { assert_equal 11, posts.size } + assert_queries(1) { assert_equal 11, posts.load.size } + 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_not_predicate posts, :loaded? + + best_posts = posts.where(comments_count: 0) + best_posts.load # force load + assert_no_queries { assert_equal 6, best_posts.size } + end + + def test_size_with_limit + posts = Post.limit(10) + + assert_queries(1) { assert_equal 10, posts.size } + assert_not_predicate posts, :loaded? + + best_posts = posts.where(comments_count: 0) + best_posts.load # force load + assert_no_queries { assert_equal 6, best_posts.size } + end + + def test_size_with_zero_limit + posts = Post.limit(0) + + assert_no_queries { assert_equal 0, posts.size } + assert_not_predicate posts, :loaded? + + posts.load # 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_not_predicate 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 => 4, 2 => 1 } + assert_equal expected, posts.count + end + + def test_empty + posts = Post.all + + assert_queries(1) { assert_equal false, posts.empty? } + assert_not_predicate posts, :loaded? + + no_posts = posts.where(title: "") + assert_queries(1) { assert_equal true, no_posts.empty? } + assert_not_predicate no_posts, :loaded? + + best_posts = posts.where(comments_count: 0) + best_posts.load # 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_not_predicate posts, :loaded? + + no_posts = posts.where(title: "") + assert_queries(1) { assert_equal true, no_posts.empty? } + assert_not_predicate 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_not_predicate posts.where(id: nil), :any? + + assert posts.any? { |p| p.id > 0 } + assert_not posts.any? { |p| p.id <= 0 } + end + + assert_predicate 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_not posts.many? { |p| p.id < 2 } + end + + assert_predicate posts, :loaded? + end + + def test_many_with_limits + posts = Post.all + + assert_predicate posts, :many? + assert_not_predicate posts.limit(1), :many? + end + + def test_none? + posts = Post.all + assert_queries(1) do + assert_not posts.none? # Uses COUNT() + end + + assert_not_predicate posts, :loaded? + + assert_queries(1) do + assert posts.none? { |p| p.id < 0 } + assert_not posts.none? { |p| p.id == 1 } + end + + assert_predicate posts, :loaded? + end + + def test_one + posts = Post.all + assert_queries(1) do + assert_not posts.one? # Uses COUNT() + end + + assert_not_predicate posts, :loaded? + + assert_queries(1) do + assert_not posts.one? { |p| p.id < 3 } + assert posts.one? { |p| p.id == 1 } + end + + assert_predicate posts, :loaded? + end + + def test_to_a_should_dup_target + posts = Post.all + + original_size = posts.size + removed = posts.to_a.pop + + assert_equal original_size, posts.size + assert_includes posts.to_a, removed + 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_not_predicate sparrow, :persisted? + + hen = birds.where(name: "hen").create + assert_predicate 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_predicate hen, :persisted? + assert_equal "hen", hen.name + end + + def test_create_with_polymorphic_association + author = authors(:david) + post = posts(:welcome) + comment = Comment.where(post: post, author: author).create!(body: "hello") + + assert_equal author, comment.author + assert_equal post, comment.post + end + + def test_first_or_create + parrot = Bird.where(color: "green").first_or_create(name: "parrot") + assert_kind_of Bird, parrot + assert_predicate 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_predicate 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_not_predicate 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_predicate 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_predicate 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_predicate 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_predicate 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_not_predicate parrot, :persisted? + assert_predicate parrot, :valid? + assert_predicate 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_not_predicate parrot, :persisted? + assert_not_predicate parrot, :valid? + assert_predicate 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_not_predicate parrot, :persisted? + assert_predicate parrot, :valid? + assert_predicate 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_predicate 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_predicate 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_create_or_find_by + assert_nil Subscriber.find_by(nick: "bob") + + subscriber = Subscriber.create!(nick: "bob") + + assert_equal subscriber, Subscriber.create_or_find_by(nick: "bob") + assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat") + end + + def test_create_or_find_by_should_not_raise_due_to_validation_errors + assert_nothing_raised do + bird = Bird.create_or_find_by(color: "green") + assert_predicate bird, :invalid? + end + end + + def test_create_or_find_by_with_non_unique_attributes + Subscriber.create!(nick: "bob", name: "the builder") + + assert_raises(ActiveRecord::RecordNotFound) do + Subscriber.create_or_find_by(nick: "bob", name: "the cat") + end + end + + def test_create_or_find_by_within_transaction + assert_nil Subscriber.find_by(nick: "bob") + + subscriber = Subscriber.create!(nick: "bob") + + Subscriber.transaction do + assert_equal subscriber, Subscriber.create_or_find_by(nick: "bob") + assert_not_equal subscriber, Subscriber.create_or_find_by(nick: "cat") + end + end + + def test_create_or_find_by_with_bang + assert_nil Subscriber.find_by(nick: "bob") + + subscriber = Subscriber.create!(nick: "bob") + + assert_equal subscriber, Subscriber.create_or_find_by!(nick: "bob") + assert_not_equal subscriber, Subscriber.create_or_find_by!(nick: "cat") + end + + def test_create_or_find_by_with_bang_should_raise_due_to_validation_errors + assert_raises(ActiveRecord::RecordInvalid) { Bird.create_or_find_by!(color: "green") } + end + + def test_create_or_find_by_with_bang_with_non_unique_attributes + Subscriber.create!(nick: "bob", name: "the builder") + + assert_raises(ActiveRecord::RecordNotFound) do + Subscriber.create_or_find_by!(nick: "bob", name: "the cat") + end + end + + def test_create_or_find_by_with_bang_within_transaction + assert_nil Subscriber.find_by(nick: "bob") + + subscriber = Subscriber.create!(nick: "bob") + + Subscriber.transaction do + assert_equal subscriber, Subscriber.create_or_find_by!(nick: "bob") + assert_not_equal subscriber, Subscriber.create_or_find_by!(nick: "cat") + end + end + + def test_find_or_initialize_by + assert_nil Bird.find_by(name: "bob") + + bird = Bird.find_or_initialize_by(name: "bob") + assert_predicate bird, :new_record? + bird.save! + + assert_equal bird, Bird.find_or_initialize_by(name: "bob") + end + + def test_explicit_create_with + 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_create_with_nested_attributes + assert_difference("Project.count", 1) do + developers = Developer.where(name: "Aaron") + developers = developers.create_with( + projects_attributes: [{ name: "p1" }] + ) + developers.create! + end + 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, all_posts.to_a + 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_ordering_with_extra_spaces + assert_equal authors(:david), Author.order("id DESC , name DESC").last + 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) + end + assert_sql(/DISTINCT/) do + assert_equal ["Foo"], query.distinct(true).map(&:name) + end + assert_equal ["Foo", "Foo"], query.distinct(true).distinct(false).map(&:name) + end + + def test_doesnt_add_having_values_if_options_are_blank + scope = Post.having("") + assert_empty scope.having_clause + + scope = Post.having([]) + assert_empty scope.having_clause + end + + def test_having_with_binds_for_both_where_and_having + post = Post.first + having_then_where = Post.having(id: post.id).where(title: post.title).group(:id) + where_then_having = Post.where(title: post.title).having(id: post.id).group(:id) + + assert_equal [post], having_then_where + assert_equal [post], where_then_having + end + + def test_multiple_where_and_having_clauses + post = Post.first + having_then_where = Post.having(id: post.id).where(title: post.title) + .having(id: post.id).where(title: post.title).group(:id) + + assert_equal [post], having_then_where + end + + def test_grouping_by_column_with_reserved_name + assert_equal [], Possession.select(:where).group(:where).to_a + end + + def test_references_triggers_eager_loading + scope = Post.includes(:comments) + assert_not_predicate scope, :eager_loading? + assert_predicate scope.references(:comments), :eager_loading? + end + + def test_references_doesnt_trigger_eager_loading_if_reference_not_included + scope = Post.references(:comments) + assert_not_predicate 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(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")) + if current_adapter?(:OracleAdapter) + assert_equal ["COMMENTS"], scope.references_values + else + assert_equal ["comments"], scope.references_values + end + + 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(Arel.sql("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(Arel.sql("#{Comment.quoted_table_name}.#{Comment.quoted_primary_key}")) + if current_adapter?(:OracleAdapter) + assert_equal ["COMMENTS"], scope.references_values + else + assert_equal ["comments"], scope.references_values + end + + 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(Arel.sql("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_not 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_predicate 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_nil Post.all.find_by("1 = 0") + end + + test "find_by doesn't have implicit ordering" do + assert_sql(/^((?!ORDER).)*$/) { Post.all.find_by(author_id: 2) } + end + + test "find_by requires at least one argument" do + assert_raises(ArgumentError) { Post.all.find_by } + 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.all.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 "find_by! requires at least one argument" do + assert_raises(ArgumentError) { Post.all.find_by! } + 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 with cached arel can't be mutated [internal API]" do + relation = Post.all + relation.arel + + assert_raises(ActiveRecord::ImmutableRelation) { relation.limit!(5) } + assert_raises(ActiveRecord::ImmutableRelation) { relation.where!("1 = 2") } + 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 "relations don't load all records in #inspect" do + assert_sql(/LIMIT|ROWNUM <=|FETCH FIRST/) do + Post.all.inspect + end + 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 + post = posts(:welcome) + + assert_equal post, custom_post_relation.where!(title: post.title).take + end + + test "using a custom table with joins affects the joins" do + post = posts(:welcome) + + assert_equal post, custom_post_relation.joins(:author).where!(title: post.title).take + end + + test "arel_attribute respects a custom table" do + assert_equal [posts(:sti_comments)], custom_post_relation.ranked_by_comments.limit_by(1).to_a + end + + test "alias_tracker respects a custom table" do + assert_equal posts(:welcome), custom_post_relation("categories_posts").joins(:categories).first + 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_not_respond_to Post.all, :by_lifo + end + + def test_unscope_with_subquery + p1 = Post.where(id: 1) + p2 = Post.where(id: 2) + + assert_not_equal p1, p2 + + comments = Comment.where(post: p1).unscope(where: :post_id).where(post: p2) + + assert_not_equal p1.first.comments, comments + assert_equal p2.first.comments, comments + end + + def test_unscope_specific_where_value + posts = Post.where(title: "Welcome to the weblog", body: "Such a lovely day") + + assert_equal 1, posts.count + assert_equal 1, posts.unscope(where: :title).count + assert_equal 1, posts.unscope(where: :body).count + end + + def test_locked_should_not_build_arel + posts = Post.locked + assert_predicate posts, :locked? + assert_nothing_raised { posts.lock!(false) } + end + + def test_relation_join_method + assert_equal "Thank you for the welcome,Thank you again for the welcome", Post.first.comments.join(",") + end + + def test_relation_with_private_kernel_method + accounts = Account.all + assert_equal [accounts(:signals37)], accounts.open + assert_equal [accounts(:signals37)], accounts.available + + sub_accounts = SubAccount.all + assert_equal [accounts(:signals37)], sub_accounts.open + assert_equal [accounts(:signals37)], sub_accounts.available + end + + test "#skip_query_cache!" do + Post.cache do + assert_queries(1) do + Post.all.load + Post.all.load + end + + assert_queries(2) do + Post.all.skip_query_cache!.load + Post.all.skip_query_cache!.load + end + end + end + + test "#skip_query_cache! with an eager load" do + Post.cache do + assert_queries(1) do + Post.eager_load(:comments).load + Post.eager_load(:comments).load + end + + assert_queries(2) do + Post.eager_load(:comments).skip_query_cache!.load + Post.eager_load(:comments).skip_query_cache!.load + end + end + end + + test "#skip_query_cache! with a preload" do + Post.cache do + assert_queries(2) do + Post.preload(:comments).load + Post.preload(:comments).load + end + + assert_queries(4) do + Post.preload(:comments).skip_query_cache!.load + Post.preload(:comments).skip_query_cache!.load + end + end + end + + test "#where with set" do + david = authors(:david) + mary = authors(:mary) + + authors = Author.where(name: ["David", "Mary"].to_set) + assert_equal [david, mary], authors + end + + test "#where with empty set" do + authors = Author.where(name: Set.new) + assert_empty authors + end + + private + def custom_post_relation(alias_name = "omg_posts") + table_alias = Post.arel_table.alias(alias_name) + table_metadata = ActiveRecord::TableMetadata.new(Post, table_alias) + predicate_builder = ActiveRecord::PredicateBuilder.new(table_metadata) + + ActiveRecord::Relation.create( + Post, + table: table_alias, + predicate_builder: predicate_builder + ) + 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..72f4bfaf6d --- /dev/null +++ b/activerecord/test/cases/reload_models_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/owner" +require "models/pet" + +class ReloadModelsTest < ActiveRecord::TestCase + include ActiveSupport::Testing::Isolation + + fixtures :pets, :owners + + 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("../models/owner.rb", __dir__)) + + pet = Pet.find_by_name("parrot") + pet.owner = Owner.find_by_name("ashley") + assert_equal pet.owner, Owner.find_by_name("ashley") + end +end unless in_memory_db? diff --git a/activerecord/test/cases/reserved_word_test.rb b/activerecord/test/cases/reserved_word_test.rb new file mode 100644 index 0000000000..e32605fd11 --- /dev/null +++ b/activerecord/test/cases/reserved_word_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "cases/helper" + +class ReservedWordTest < ActiveRecord::TestCase + self.use_instantiated_fixtures = true + self.use_transactional_tests = false + + 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 + + def setup + @connection = ActiveRecord::Base.connection + @connection.create_table :select, force: true + @connection.create_table :distinct, force: true + @connection.create_table :distinct_select, id: false, force: true do |t| + t.belongs_to :distinct + t.belongs_to :select + end + @connection.create_table :group, force: true do |t| + t.string :order + t.belongs_to :select + end + @connection.create_table :values, primary_key: :as, force: true do |t| + t.belongs_to :group + end + end + + def teardown + @connection.drop_table :select, if_exists: true + @connection.drop_table :distinct, if_exists: true + @connection.drop_table :distinct_select, if_exists: true + @connection.drop_table :group, if_exists: true + @connection.drop_table :values, if_exists: true + @connection.drop_table :order, if_exists: true + end + + def test_create_tables + assert_not @connection.table_exists?(:order) + + @connection.create_table :order do |t| + t.string :group + end + + assert @connection.table_exists?(:order) + end + + def test_rename_tables + assert_nothing_raised { @connection.rename_table(:group, :order) } + end + + def test_change_columns + assert_nothing_raised { @connection.change_column_default(:group, :order, "whatever") } + assert_nothing_raised { @connection.change_column("group", "order", :text, default: nil) } + assert_nothing_raised { @connection.rename_column(:group, :order, :values) } + end + + def test_introspect + assert_equal ["id", "order", "select_id"], @connection.columns(:group).map(&:name).sort + assert_equal ["index_group_on_select_id"], @connection.indexes(:group).map(&:name).sort + end + + def test_activerecord_model + x = Group.new + x.order = "x" + x.save! + x.order = "y" + x.save! + assert_equal x, Group.find_by_order("y") + assert_equal x, Group.find(x.id) + end + + def test_delete_all_with_subselect + create_test_fixtures :values + assert_equal 1, Values.order(:as).limit(1).offset(1).delete_all + assert_raise(ActiveRecord::RecordNotFound) { Values.find(2) } + assert Values.find(1) + end + + def test_has_one_associations + create_test_fixtures :group, :values + v = Group.find(1).values + assert_equal 2, v.id + end + + def test_belongs_to_associations + create_test_fixtures :select, :group + gs = Select.find(2).groups + assert_equal 2, gs.length + assert_equal [2, 3], gs.collect(&:id).sort + end + + def test_has_and_belongs_to_many + create_test_fixtures :select, :distinct, :distinct_select + s = Distinct.find(1).selects + assert_equal 2, s.length + assert_equal [1, 2], s.collect(&:id).sort + end + + def test_activerecord_introspection + assert_predicate Group, :table_exists? + assert_equal ["id", "order", "select_id"], Group.columns.map(&:name).sort + end + + def test_calculations_work_with_reserved_words + create_test_fixtures :group + assert_equal 3, Group.count + end + + def test_associations_work_with_reserved_words + create_test_fixtures :select, :group + selects = Select.all.merge!(includes: [:groups]).to_a + assert_no_queries do + selects.each { |select| select.groups } + end + end + + 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 +end diff --git a/activerecord/test/cases/result_test.rb b/activerecord/test/cases/result_test.rb new file mode 100644 index 0000000000..825aee2423 --- /dev/null +++ b/activerecord/test/cases/result_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +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 "includes_column?" do + assert result.includes_column?("col_1") + assert_not result.includes_column?("foo") + end + + test "length" do + assert_equal 3, result.length + end + + test "to_a 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_a + end + + test "to_hash (deprecated) returns row_hashes" do + assert_deprecated 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 + end + + test "first returns first row as a hash" do + assert_equal( + { "col_1" => "row 1 col 1", "col_2" => "row 1 col 2" }, result.first) + end + + test "last returns last row as a hash" do + assert_equal( + { "col_1" => "row 3 col 1", "col_2" => "row 3 col 2" }, result.last) + 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 + + test "each without block returns a sized enumerator" do + assert_equal 3, result.each.size + 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..778cf86ac3 --- /dev/null +++ b/activerecord/test/cases/sanitize_test.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/binary" +require "models/author" +require "models/post" +require "models/customer" + +class SanitizeTest < ActiveRecord::TestCase + def setup + end + + def test_sanitize_sql_array_handles_string_interpolation + quoted_bambi = ActiveRecord::Base.connection.quote_string("Bambi") + assert_equal "name='#{quoted_bambi}'", Binary.sanitize_sql_array(["name='%s'", "Bambi"]) + assert_equal "name='#{quoted_bambi}'", Binary.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.sanitize_sql_array(["name='%s'", "Bambi\nand\nThumper"]) + assert_equal "name='#{quoted_bambi_and_thumper}'", Binary.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.sanitize_sql_array(["name=?", "Bambi"]) + assert_equal "name=#{quoted_bambi}", Binary.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.sanitize_sql_array(["name=?", "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=?", "Bambi\nand\nThumper".mb_chars]) + end + + def test_sanitize_sql_array_handles_named_bind_variables + quoted_bambi = ActiveRecord::Base.connection.quote("Bambi") + assert_equal "name=#{quoted_bambi}", Binary.sanitize_sql_array(["name=:name", name: "Bambi"]) + assert_equal "name=#{quoted_bambi} AND id=1", Binary.sanitize_sql_array(["name=:name AND id=:id", name: "Bambi", id: 1]) + + quoted_bambi_and_thumper = ActiveRecord::Base.connection.quote("Bambi\nand\nThumper") + assert_equal "name=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name", name: "Bambi\nand\nThumper"]) + assert_equal "name=#{quoted_bambi_and_thumper} AND name2=#{quoted_bambi_and_thumper}", Binary.sanitize_sql_array(["name=:name AND name2=:name", name: "Bambi\nand\nThumper"]) + 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.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.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.sanitize_sql_array([""]) + assert_equal("", select_author_sql) + end + + def test_sanitize_sql_like + assert_equal '100\%', Binary.sanitize_sql_like("100%") + assert_equal 'snake\_cased\_string', Binary.sanitize_sql_like("snake_cased_string") + assert_equal 'C:\\\\Programs\\\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint') + assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42") + end + + def test_sanitize_sql_like_with_custom_escape_character + assert_equal "100!%", Binary.sanitize_sql_like("100%", "!") + assert_equal "snake!_cased!_string", Binary.sanitize_sql_like("snake_cased_string", "!") + assert_equal "great!!", Binary.sanitize_sql_like("great!", "!") + assert_equal 'C:\\Programs\\MsPaint', Binary.sanitize_sql_like('C:\\Programs\\MsPaint', "!") + assert_equal "normal string 42", Binary.sanitize_sql_like("normal string 42", "!") + end + + def test_sanitize_sql_like_example_use_case + searchable_post = Class.new(Post) do + def self.search_as_method(term) + where("title LIKE ?", sanitize_sql_like(term, "!")) + end + + scope :search_as_scope, -> (term) { + where("title LIKE ?", sanitize_sql_like(term, "!")) + } + end + + assert_sql(/LIKE '20!% !_reduction!_!!'/) do + searchable_post.search_as_method("20% _reduction_!").to_a + end + + assert_sql(/LIKE '20!% !_reduction!_!!'/) do + searchable_post.search_as_scope("20% _reduction_!").to_a + end + 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") } + end + + def test_named_bind_arity + assert_nothing_raised { bind "name = :name", name: "37signals" } + assert_nothing_raised { bind "name = :name", name: "37signals", id: 1 } + assert_raise(ActiveRecord::PreparedStatementInvalid) { bind "name = :name", id: 1 } + 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_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_deprecated_expand_hash_conditions_for_aggregates + assert_deprecated do + assert_equal({ "balance" => 50 }, Customer.send(:expand_hash_conditions_for_aggregates, balance: Money.new(50))) + end + end + + private + 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 +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..dda3efa47c --- /dev/null +++ b/activerecord/test/cases/schema_dumper_test.rb @@ -0,0 +1,521 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +class SchemaDumperTest < ActiveRecord::TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + setup do + ActiveRecord::SchemaMigration.create_table + end + + def standard_dump + @@standard_dump ||= perform_schema_dump + end + + def perform_schema_dump + dump_all_table_schema [] + end + + def test_dump_schema_information_with_empty_versions + ActiveRecord::SchemaMigration.delete_all + schema_info = ActiveRecord::Base.connection.dump_schema_information + assert_no_match(/INSERT INTO/, schema_info) + 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) + ensure + ActiveRecord::SchemaMigration.delete_all + 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{(?<=, ) do \|t\|}, output + assert_no_match %r{create_table "schema_migrations"}, output + assert_no_match %r{create_table "ar_internal_metadata"}, output + end + + def test_schema_dump_uses_force_cascade_on_create_table + output = dump_table_schema "authors" + assert_match %r{create_table "authors",.* force: :cascade}, 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_no_line_up(lines, pattern) + return assert(true) if lines.empty? + matches = lines.map { |line| line.match(pattern) } + matches.compact! + return assert(true) if matches.empty? + line_matches = lines.map { |line| [line, line.match(pattern)] }.select { |line, match| match } + assert line_matches.all? { |line, match| + start = match.offset(0).first + line[start - 2..start - 1] == ", " + } + 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_no_line_up + column_definition_lines.each do |column_set| + next if column_set.empty? + + assert column_set.all? { |column| !column.match(/\bt\.\w+\s{2,}/) } + end + end + + def test_arguments_no_line_up + column_definition_lines.each do |column_set| + assert_no_line_up(column_set, /default: /) + assert_no_line_up(column_set, /limit: /) + assert_no_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 + output = dump_all_table_schema([/^[^r]/]) + assert_match %r{null: false}, output + end + + def test_schema_dump_includes_limit_constraint_for_integer_columns + output = dump_all_table_schema([/^(?!integer_limits)/]) + + assert_match %r{"c_int_without_limit"(?!.*limit)}, output + + 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"(?!.*limit)}, output + assert_match %r{"c_int_4"(?!.*limit)}, output + elsif current_adapter?(: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"(?!.*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 + + if current_adapter?(:SQLite3Adapter, :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{t\.bigint\s+"c_int_5"$}, output + assert_match %r{t\.bigint\s+"c_int_6"$}, output + assert_match %r{t\.bigint\s+"c_int_7"$}, output + assert_match %r{t\.bigint\s+"c_int_8"$}, output + end + end + + def test_schema_dump_with_string_ignored_table + output = dump_all_table_schema(["accounts"]) + assert_no_match %r{create_table "accounts"}, output + assert_match %r{create_table "authors"}, output + assert_no_match %r{create_table "schema_migrations"}, output + assert_no_match %r{create_table "ar_internal_metadata"}, output + end + + def test_schema_dump_with_regexp_ignored_table + output = dump_all_table_schema([/^account/]) + assert_no_match %r{create_table "accounts"}, output + assert_match %r{create_table "authors"}, output + assert_no_match %r{create_table "schema_migrations"}, output + assert_no_match %r{create_table "ar_internal_metadata"}, output + end + + def test_schema_dumps_index_columns_in_right_order + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_index/).first.strip + if current_adapter?(:Mysql2Adapter) + if ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }, order: { rating: :desc }', index_definition + else + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", length: { type: 10 }', index_definition + end + elsif ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index", order: { rating: :desc }', index_definition + else + assert_equal 't.index ["firm_id", "type", "rating"], name: "company_index"', index_definition + end + end + + def test_schema_dumps_partial_indices + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_partial_index/).first.strip + if ActiveRecord::Base.connection.supports_partial_index? + assert_equal 't.index ["firm_id", "type"], name: "company_partial_index", where: "(rating > 10)"', index_definition + else + assert_equal 't.index ["firm_id", "type"], name: "company_partial_index"', index_definition + end + end + + def test_schema_dumps_index_sort_order + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_rating/).first.strip + if ActiveRecord::Base.connection.supports_index_sort_order? + assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating", order: :desc', index_definition + else + assert_equal 't.index ["name", "rating"], name: "index_companies_on_name_and_rating"', index_definition + end + end + + def test_schema_dumps_index_length + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*_name_and_description/).first.strip + if current_adapter?(:Mysql2Adapter) + assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description", length: 10', index_definition + else + assert_equal 't.index ["name", "description"], name: "index_companies_on_name_and_description"', 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 = dump_table_schema "booleans" + assert_match %r{t\.boolean\s+"has_fun",.+default: false}, output + end + + def test_schema_dump_does_not_include_limit_for_text_field + output = dump_table_schema "admin_users" + assert_match %r{t\.text\s+"params"$}, output + end + + def test_schema_dump_does_not_include_limit_for_binary_field + output = dump_table_schema "binaries" + assert_match %r{t\.binary\s+"data"$}, output + end + + def test_schema_dump_does_not_include_limit_for_float_field + output = dump_table_schema "numeric_data" + assert_match %r{t\.float\s+"temperature"$}, output + end + + if ActiveRecord::Base.connection.supports_expression_index? + def test_schema_dump_expression_indices + index_definition = dump_table_schema("companies").split(/\n/).grep(/t\.index.*company_expression_index/).first.strip + index_definition.sub!(/, name: "company_expression_index"\z/, "") + + if current_adapter?(:PostgreSQLAdapter) + assert_match %r{CASE.+lower\(\(name\)::text\).+END\) DESC"\z}i, index_definition + elsif current_adapter?(:Mysql2Adapter) + assert_match %r{CASE.+lower\(`name`\).+END\) DESC"\z}i, index_definition + elsif current_adapter?(:SQLite3Adapter) + assert_match %r{CASE.+lower\(name\).+END\) DESC"\z}i, index_definition + else + assert false + end + end + end + + if current_adapter?(:Mysql2Adapter) + 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\.blob\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: 4294967295$}, 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: 4294967295$}, output + end + + def test_schema_does_not_include_limit_for_emulated_mysql_boolean_fields + output = standard_dump + assert_no_match %r{t\.boolean\s+"has_fun",.+limit: 1}, output + end + + def test_schema_dumps_index_type + output = dump_table_schema "key_tests" + assert_match %r{t\.index \["awesome"\], name: "index_key_tests_on_awesome", type: :fulltext$}, output + assert_match %r{t\.index \["pizza"\], name: "index_key_tests_on_pizza"$}, output + end + end + + def test_schema_dump_includes_decimal_options + output = dump_all_table_schema([/^[^n]/]) + 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 = dump_table_schema "defaults" + assert_match %r{t\.bigint\s+"bigint_default",\s+default: 0}, output + end + + def test_schema_dump_includes_limit_on_array_type + output = dump_table_schema "bigint_array" + assert_match %r{t\.bigint\s+"big_int_data_points\",\s+array: true}, output + end + + def test_schema_dump_allows_array_of_decimal_defaults + output = dump_table_schema "bigint_array" + assert_match %r{t\.decimal\s+"decimal_array_default",\s+default: \["1.23", "3.45"\],\s+array: true}, output + end + + def test_schema_dump_interval_type + output = dump_table_schema "postgresql_times" + assert_match %r{t\.interval\s+"time_interval"$}, output + assert_match %r{t\.interval\s+"scaled_time_interval",\s+precision: 6$}, output + end + + def test_schema_dump_oid_type + output = dump_table_schema "postgresql_oids" + assert_match %r{t\.oid\s+"obj_id"$}, output + end + + def test_schema_dump_includes_extensions + connection = ActiveRecord::Base.connection + + connection.stub(:extensions, ["hstore"]) do + output = perform_schema_dump + assert_match "# These are extensions that must be enabled", output + assert_match %r{enable_extension "hstore"}, output + end + + connection.stub(:extensions, []) do + output = perform_schema_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_extensions_in_alphabetic_order + connection = ActiveRecord::Base.connection + + connection.stub(:extensions, ["hstore", "uuid-ossp", "xml2"]) do + output = perform_schema_dump + enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten + assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions + end + + connection.stub(:extensions, ["uuid-ossp", "xml2", "hstore"]) do + output = perform_schema_dump + enabled_extensions = output.scan(%r{enable_extension "(.+)"}).flatten + assert_equal ["hstore", "uuid-ossp", "xml2"], enabled_extensions + 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 + elsif current_adapter?(:FbAdapter) + assert_match %r{t\.integer\s+"atoms_in_universe",\s+precision: 18}, 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\s+"id",.*?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 "string_key_objects", 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 + + def test_do_not_dump_foreign_keys_for_ignored_tables + output = dump_table_schema "authors" + assert_equal ["authors"], output.scan(/^\s*add_foreign_key "([^"]+)".+$/).flatten + end + end + + class CreateDogMigration < ActiveRecord::Migration::Current + def up + create_table("dog_owners") do |t| + end + + create_table("dogs") do |t| + t.column :name, :string + t.references :owner + t.index [:name] + t.foreign_key :dog_owners, column: "owner_id" + end + 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 = perform_schema_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 + assert_no_match %r{create_table "ar_internal_metadata"}, 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 + + def test_schema_dump_with_table_name_prefix_and_suffix_regexp_escape + original, $stdout = $stdout, StringIO.new + ActiveRecord::Base.table_name_prefix = "foo$" + ActiveRecord::Base.table_name_suffix = "$bar" + + migration = CreateDogMigration.new + migration.migrate(:up) + + output = perform_schema_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 + assert_no_match %r{create_table "ar_internal_metadata"}, 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 + + def test_schema_dump_with_table_name_prefix_and_ignoring_tables + original, $stdout = $stdout, StringIO.new + + create_cat_migration = Class.new(ActiveRecord::Migration::Current) do + def change + create_table("cats") do |t| + end + create_table("omg_cats") do |t| + end + end + end + + original_table_name_prefix = ActiveRecord::Base.table_name_prefix + original_schema_dumper_ignore_tables = ActiveRecord::SchemaDumper.ignore_tables + ActiveRecord::Base.table_name_prefix = "omg_" + ActiveRecord::SchemaDumper.ignore_tables = ["cats"] + migration = create_cat_migration.new + migration.migrate(:up) + + stream = StringIO.new + output = ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream).string + + assert_match %r{create_table "omg_cats"}, output + assert_no_match %r{create_table "cats"}, output + ensure + migration.migrate(:down) + ActiveRecord::Base.table_name_prefix = original_table_name_prefix + ActiveRecord::SchemaDumper.ignore_tables = original_schema_dumper_ignore_tables + + $stdout = original + end +end + +class SchemaDumperDefaultsTest < ActiveRecord::TestCase + include SchemaDumpingHelper + + setup do + @connection = ActiveRecord::Base.connection + @connection.create_table :dump_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" + t.decimal :decimal_with_default, default: "1234567890.0123456789", precision: 20, scale: 10 + end + + if current_adapter?(:PostgreSQLAdapter) + @connection.create_table :infinity_defaults, force: true do |t| + t.float :float_with_inf_default, default: Float::INFINITY + t.float :float_with_nan_default, default: Float::NAN + end + end + end + + teardown do + @connection.drop_table "dump_defaults", if_exists: true + end + + def test_schema_dump_defaults_with_universally_supported_types + output = dump_table_schema("dump_defaults") + + assert_match %r{t\.string\s+"string_with_default",.*?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 + assert_match %r{t\.decimal\s+"decimal_with_default",\s+precision: 20,\s+scale: 10,\s+default: "1234567890.0123456789"}, output + end + + def test_schema_dump_with_float_column_infinity_default + skip unless current_adapter?(:PostgreSQLAdapter) + output = dump_table_schema("infinity_defaults") + assert_match %r{t\.float\s+"float_with_inf_default",\s+default: ::Float::INFINITY}, output + assert_match %r{t\.float\s+"float_with_nan_default",\s+default: ::Float::NAN}, output + end +end diff --git a/activerecord/test/cases/schema_loading_test.rb b/activerecord/test/cases/schema_loading_test.rb new file mode 100644 index 0000000000..f539156466 --- /dev/null +++ b/activerecord/test/cases/schema_loading_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "cases/helper" + +module SchemaLoadCounter + extend ActiveSupport::Concern + + module ClassMethods + attr_accessor :load_schema_calls + + def load_schema! + self.load_schema_calls ||= 0 + self.load_schema_calls += 1 + super + end + end +end + +class SchemaLoadingTest < ActiveRecord::TestCase + def test_basic_model_is_loaded_once + klass = define_model + klass.new + assert_equal 1, klass.load_schema_calls + end + + def test_model_with_custom_lock_is_loaded_once + klass = define_model do |c| + c.table_name = :lock_without_defaults_cust + c.locking_column = :custom_lock_version + end + klass.new + assert_equal 1, klass.load_schema_calls + end + + def test_model_with_changed_custom_lock_is_loaded_twice + klass = define_model do |c| + c.table_name = :lock_without_defaults_cust + end + klass.new + klass.locking_column = :custom_lock_version + klass.new + assert_equal 2, klass.load_schema_calls + end + + private + + def define_model + Class.new(ActiveRecord::Base) do + include SchemaLoadCounter + self.table_name = :lock_without_defaults + yield self if block_given? + end + 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..6281712df6 --- /dev/null +++ b/activerecord/test/cases/scoping/default_scoping_test.rb @@ -0,0 +1,575 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" +require "models/developer" +require "models/project" +require "models/computer" +require "models/vehicle" +require "models/cat" +require "concurrent/atomic/cyclic_barrier" + +class DefaultScopingTest < ActiveRecord::TestCase + fixtures :developers, :posts, :comments + + def test_default_scope + expected = Developer.all.merge!(order: "salary DESC").to_a.collect(&:salary) + received = DeveloperOrderedBySalary.all.collect(&: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_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_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(&:name) + received = DeveloperOrderedBySalary.by_name.to_a.collect(&:name) + assert_equal expected, received + end + + def test_reorder_overrides_default_scope_order + expected = Developer.order("name DESC").collect(&:name) + received = DeveloperOrderedBySalary.reorder("name DESC").collect(&: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.sort, received.sort + + 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.sort, received_2.sort + + 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.sort, received_3.sort + + expected_4 = Developer.order("salary DESC").collect(&:name) + received_4 = DeveloperOrderedBySalary.where.not("name" => "Jamis").unscope(where: :name).collect(&:name) + assert_equal expected_4.sort, received_4.sort + + 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.sort, received_5.sort + + expected_6 = Developer.order("salary DESC").collect(&:name) + received_6 = DeveloperOrderedBySalary.where(Developer.arel_table["name"].eq("David")).unscope(where: :name).collect(&:name) + assert_equal expected_6.sort, received_6.sort + + expected_7 = Developer.order("salary DESC").collect(&:name) + received_7 = DeveloperOrderedBySalary.where(Developer.arel_table[:name].eq("David")).unscope(where: :name).collect(&:name) + assert_equal expected_7.sort, received_7.sort + end + + def test_unscope_comparison_where_clauses + # unscoped for WHERE (`developers`.`id` <= 2) + expected = Developer.order("salary DESC").collect(&:name) + received = DeveloperOrderedBySalary.where(id: -Float::INFINITY..2).unscope(where: :id).collect { |dev| dev.name } + assert_equal expected.sort, received.sort + + # unscoped for WHERE (`developers`.`id` < 2) + expected = Developer.order("salary DESC").collect(&:name) + received = DeveloperOrderedBySalary.where(id: -Float::INFINITY...2).unscope(where: :id).collect { |dev| dev.name } + assert_equal expected.sort, received.sort + end + + def test_unscope_multiple_where_clauses + expected = Developer.order("salary DESC").collect(&:name) + received = DeveloperOrderedBySalary.where(name: "Jamis").where(id: 1).unscope(where: [:name, :id]).collect(&:name) + assert_equal expected.sort, received.sort + end + + def test_unscope_string_where_clauses_involved + dev_relation = Developer.order("salary DESC").where("created_at > ?", 1.year.ago) + expected = dev_relation.collect(&:name) + + dev_ordered_relation = DeveloperOrderedBySalary.where(name: "Jamis").where("created_at > ?", 1.year.ago) + received = dev_ordered_relation.unscope(where: [:name]).collect(&:name) + + assert_equal expected.sort, received.sort + end + + def test_unscope_with_grouping_attributes + expected = Developer.order("salary DESC").collect(&:name) + received = DeveloperOrderedBySalary.group(:name).unscope(:group).collect(&:name) + assert_equal expected.sort, received.sort + + expected_2 = Developer.order("salary DESC").collect(&:name) + received_2 = DeveloperOrderedBySalary.group("name").unscope(:group).collect(&:name) + assert_equal expected_2.sort, received_2.sort + end + + def test_unscope_with_limit_in_query + expected = Developer.order("salary DESC").collect(&:name) + received = DeveloperOrderedBySalary.limit(1).unscope(:limit).collect(&:name) + assert_equal expected.sort, received.sort + end + + def test_order_to_unscope_reordering + scope = DeveloperOrderedBySalary.order("salary DESC, name ASC").reverse_order.unscope(:order) + assert_no_match(/order/i, scope.to_sql) + end + + def test_unscope_reverse_order + expected = Developer.all.collect(&:name) + received = Developer.order("salary DESC").reverse_order.unscope(:order).collect(&:name) + assert_equal expected, received + end + + def test_unscope_select + expected = Developer.order("salary ASC").collect(&:name) + received = Developer.order("salary DESC").reverse_order.select(:name).unscope(:select).collect(&:name) + assert_equal expected, received + + expected_2 = Developer.all.collect(&:id) + received_2 = Developer.select(:name).unscope(:select).collect(&:id) + assert_equal expected_2, received_2 + end + + def test_unscope_offset + expected = Developer.all.collect(&:name) + received = Developer.offset(5).unscope(:offset).collect(&:name) + assert_equal expected, received + end + + def test_unscope_joins_and_select_on_developers_projects + expected = Developer.all.collect(&:name) + received = Developer.joins("JOIN developers_projects ON id = developer_id").select(:id).unscope(:joins, :select).collect(&:name) + assert_equal expected, received + end + + def test_unscope_left_outer_joins + expected = Developer.all.collect(&:name) + received = Developer.left_outer_joins(:projects).select(:id).unscope(:left_outer_joins, :select).collect(&:name) + assert_equal expected, received + end + + def test_unscope_left_joins + expected = Developer.all.collect(&:name) + received = Developer.left_joins(:projects).select(:id).unscope(:left_joins, :select).collect(&:name) + assert_equal expected, received + end + + def test_unscope_includes + expected = Developer.all.collect(&:name) + received = Developer.includes(:projects).select(:id).unscope(:includes, :select).collect(&:name) + assert_equal expected, received + end + + def test_unscope_having + expected = DeveloperOrderedBySalary.all.collect(&:name) + received = DeveloperOrderedBySalary.having("name IN ('Jamis', 'David')").unscope(:having).collect(&: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_empty merged.where_clause + assert_not_empty merged.where(name: "Jon").where_clause + end + + def test_order_in_default_scope_should_not_prevail + expected = Developer.all.merge!(order: "salary desc").to_a.collect(&:salary) + received = DeveloperOrderedBySalary.all.merge!(order: "salary").to_a.collect(&: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_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_using_both_string_and_symbol + jamis = PoorDeveloperCalledJamis.create_with(name: "foo").create_with("name" => "Aaron").new + assert_equal "Aaron", jamis.name + end + + def test_create_with_reset + jamis = PoorDeveloperCalledJamis.create_with(name: "Aaron").create_with(nil).new + assert_equal "Jamis", jamis.name + end + + def test_create_with_takes_precedence_over_where + developer = Developer.where(name: nil).create_with(name: "Aaron").new + assert_equal "Aaron", developer.name + end + + def test_create_with_nested_attributes + assert_difference("Project.count", 1) do + Developer.create_with( + projects_attributes: [{ name: "p1" }] + ).scoping do + Developer.create!(name: "Aaron") + end + end + 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_includes DeveloperCalledJamis.unscoped.poor, 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_with_joins + assert_equal Comment.where(post_id: SpecialPostWithDefaultScope.pluck(:id)).count, + Comment.joins(:special_post_with_default_scope).count + assert_equal Comment.where(post_id: Post.pluck(:id)).count, + Comment.joins(:post).count + end + + def test_joins_not_affected_by_scope_other_than_default_or_unscoped + without_scope_on_post = Comment.joins(:post).to_a + with_scope_on_post = nil + Post.where(id: [1, 5, 6]).scoping do + with_scope_on_post = Comment.joins(:post).to_a + end + + assert_equal with_scope_on_post, without_scope_on_post + end + + def test_unscoped_with_joins_should_not_have_default_scope + assert_equal SpecialPostWithDefaultScope.unscoped { Comment.joins(:special_post_with_default_scope).to_a }, + Comment.joins(:post).to_a + end + + def test_sti_association_with_unscoped_not_affected_by_default_scope + post = posts(:thinking) + comments = [comments(:does_it_hurt)] + + post.special_comments.update_all(deleted_at: Time.now) + + assert_raises(ActiveRecord::RecordNotFound) { Post.joins(:special_comments).find(post.id) } + assert_equal [], post.special_comments + + SpecialComment.unscoped do + assert_equal post, Post.joins(:special_comments).find(post.id) + assert_equal comments, Post.joins(:special_comments).find(post.id).special_comments + assert_equal comments, Post.eager_load(:special_comments).find(post.id).special_comments + assert_equal comments, Post.includes(:special_comments).find(post.id).special_comments + assert_equal comments, Post.preload(:special_comments).find(post.id).special_comments + end + 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 + + def test_default_scope_with_references_works_through_collection_association + post = PostWithCommentWithDefaultScopeReferencesAssociation.create!(title: "Hello World", body: "Here we go.") + comment = post.comment_with_default_scope_references_associations.create!(body: "Great post.", developer_id: Developer.first.id) + assert_equal comment, post.comment_with_default_scope_references_associations.to_a.first + end + + def test_default_scope_with_references_works_through_association + post = PostWithCommentWithDefaultScopeReferencesAssociation.create!(title: "Hello World", body: "Here we go.") + comment = post.comment_with_default_scope_references_associations.create!(body: "Great post.", developer_id: Developer.first.id) + assert_equal comment, post.first_comment + end + + def test_default_scope_with_references_works_with_find_by + post = PostWithCommentWithDefaultScopeReferencesAssociation.create!(title: "Hello World", body: "Here we go.") + comment = post.comment_with_default_scope_references_associations.create!(body: "Great post.", developer_id: Developer.first.id) + assert_equal comment, CommentWithDefaultScopeReferencesAssociation.find_by(id: comment.id) + end + + test "additional conditions are ANDed with the default scope" do + scope = DeveloperCalledJamis.where(name: "David") + assert_equal 2, scope.where_clause.ast.children.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_clause.ast.children.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_clause.ast.children.length + assert_equal Developer.where(name: "David").map(&:id), scope.map(&:id) + end + + def test_with_abstract_class_where_clause_should_not_be_duplicated + scope = Bus.all + assert_equal scope.where_clause.ast.children.length, 1 + end + + def test_sti_conditions_are_not_carried_in_default_scope + ConditionalStiPost.create! body: "" + SubConditionalStiPost.create! body: "" + SubConditionalStiPost.create! title: "Hello world", body: "" + + assert_equal 2, ConditionalStiPost.count + assert_equal 2, ConditionalStiPost.all.to_a.size + assert_equal 3, ConditionalStiPost.unscope(where: :title).to_a.size + + assert_equal 1, SubConditionalStiPost.count + assert_equal 1, SubConditionalStiPost.all.to_a.size + assert_equal 2, SubConditionalStiPost.unscope(where: :title).to_a.size + end + + def test_with_abstract_class_scope_should_be_executed_in_correct_context + vegetarian_pattern, gender_pattern = if current_adapter?(:Mysql2Adapter) + [/`lions`.`is_vegetarian`/, /`lions`.`gender`/] + elsif current_adapter?(:OracleAdapter) + [/"LIONS"."IS_VEGETARIAN"/, /"LIONS"."GENDER"/] + else + [/"lions"."is_vegetarian"/, /"lions"."gender"/] + end + + assert_match vegetarian_pattern, Lion.all.to_sql + assert_match gender_pattern, Lion.female.to_sql + end +end + +class DefaultScopingWithThreadTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + def test_default_scoping_with_threads + 2.times do + Thread.new { + assert_includes DeveloperOrderedBySalary.all.to_sql, "salary DESC" + DeveloperOrderedBySalary.connection.close + }.join + end + end + + def test_default_scope_is_threadsafe + 2.times { ThreadsafeDeveloper.unscoped.create! } + + threads = [] + assert_not_equal 1, ThreadsafeDeveloper.unscoped.count + + barrier_1 = Concurrent::CyclicBarrier.new(2) + barrier_2 = Concurrent::CyclicBarrier.new(2) + + threads << Thread.new do + Thread.current[:default_scope_delay] = -> { barrier_1.wait; barrier_2.wait } + assert_equal 1, ThreadsafeDeveloper.all.to_a.count + ThreadsafeDeveloper.connection.close + end + threads << Thread.new do + Thread.current[:default_scope_delay] = -> { barrier_2.wait } + barrier_1.wait + assert_equal 1, ThreadsafeDeveloper.all.to_a.count + ThreadsafeDeveloper.connection.close + end + threads.each(&:join) + ensure + ThreadsafeDeveloper.unscoped.destroy_all + end +end unless in_memory_db? 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..f707951a16 --- /dev/null +++ b/activerecord/test/cases/scoping/named_scoping_test.rb @@ -0,0 +1,601 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/topic" +require "models/comment" +require "models/reply" +require "models/author" +require "models/developer" +require "models/computer" + +class NamedScopingTest < ActiveRecord::TestCase + fixtures :posts, :authors, :topics, :comments, :author_addresses + + def test_implements_enumerable + assert_not_empty Topic.all + + 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 { true } + all_posts.collect { true } + end + end + + def test_reload_expires_cache_of_found_items + all_posts = Topic.base + all_posts.to_a + + new_post = Topic.create! + assert_not_includes all_posts, new_post + assert_includes all_posts.reload, new_post + end + + def test_delegates_finds_and_calculations_to_the_base_class + assert_not_empty Topic.all + + 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_calling_merge_at_first_in_scope + Topic.class_eval do + scope :calling_merge_at_first_in_scope, Proc.new { merge(Topic.replied) } + end + assert_equal Topic.calling_merge_at_first_in_scope.to_a, Topic.replied.to_a + 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_respond_to Topic.approved, :limit + assert_respond_to Topic.approved, :count + assert_respond_to Topic.approved, :length + end + + def test_scopes_with_options_limit_finds_to_those_matching_the_criteria_specified + assert_not_empty Topic.all.merge!(where: { approved: true }).to_a + + 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_not (approved == replied) + assert_not_empty (approved & replied) + + 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_not_empty Post.containing_the_letter_a + + expected = authors(:david).posts & Post.containing_the_letter_a + assert_equal expected.sort_by(&:id), authors(:david).posts.containing_the_letter_a.sort_by(&:id) + 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_not_empty Comment.containing_the_letter_e + + expected = authors(:david).comments & Comment.containing_the_letter_e + assert_equal expected.sort_by(&:id), authors(:david).comments.containing_the_letter_e.sort_by(&:id) + end + + def test_scopes_honor_current_scopes_from_when_defined + assert_not_empty Post.ranked_by_comments.limit_by(5) + assert_not_empty authors(:david).posts.ranked_by_comments.limit_by(5) + 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_scopes_body_is_a_callable + e = assert_raises ArgumentError do + Class.new(Post).class_eval { scope :containing_the_letter_z, where("body LIKE '%z%'") } + end + assert_equal "The scope body needs to be callable.", e.message + end + + def test_scopes_name_is_relation_method + conflicts = [ + :records, + :to_ary, + :to_sql, + :explain + ] + + conflicts.each do |name| + e = assert_raises ArgumentError do + Class.new(Post).class_eval { scope name, -> { where(approved: true) } } + end + assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message) + end + end + + def test_active_records_have_scope_named__all__ + assert_not_empty Topic.all + + assert_equal Topic.all.to_a, Topic.base + end + + def test_active_records_have_scope_named__scoped__ + scope = Topic.where("content LIKE '%Have%'") + assert_not_empty scope + + 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.order("id").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.load # 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.load # 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.load # 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 + assert_not_called(topics, :empty?) do + topics.any? { true } + end + end + end + + def test_any_should_not_fire_query_if_scope_loaded + topics = Topic.base + topics.load # force load + assert_no_queries { assert topics.any? } + end + + def test_model_class_should_respond_to_any + assert_predicate Topic, :any? + Topic.delete_all + assert_not_predicate Topic, :any? + end + + def test_many_should_not_load_results + topics = Topic.base + assert_queries(2) do + topics.many? # use count query + topics.load # 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 + assert_not_called(topics, :size) do + topics.many? { true } + end + end + end + + def test_many_should_not_fire_query_if_scope_loaded + topics = Topic.base + topics.load # 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_not_predicate topics, :many? + topics = Topic.base.where(id: 1) + assert_not_predicate topics, :many? + end + + def test_many_should_return_true_if_more_than_one + assert_predicate Topic.base, :many? + end + + def test_model_class_should_respond_to_many + Topic.delete_all + assert_not_predicate Topic, :many? + Topic.create! + assert_not_predicate Topic, :many? + Topic.create! + assert_predicate 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_deprecated_delegating_private_method + assert_deprecated do + scope = Topic.all.by_private_lifo + assert_not scope.instance_variable_get(:@delegate_to_klass) + end + 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, # some important methods on Module and Class + :protected, + :private, + :name, + :parent, + :superclass + ] + + 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| + e = assert_raises(ArgumentError, "scope `#{name}` should not be allowed") do + klass.class_eval { scope name, -> { where(approved: true) } } + end + assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message) + + e = assert_raises(ArgumentError, "scope `#{name}` should not be allowed") do + subklass.class_eval { scope name, -> { where(approved: true) } } + end + assert_match(/You tried to define a scope named \"#{name}\" on the model/, e.message) + 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.load # force load + assert_no_queries do + topics.size # use loaded (no query) + end + end + + def test_should_not_duplicates_where_values + relation = Topic.where("1=1") + assert_equal relation.where_clause, relation.scope_with_lambda.where_clause + 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_not_predicate post, :approved? + + post = Topic.rejected.approved.new + assert_predicate post, :approved? + + post = Topic.approved.rejected.new + assert_not_predicate post, :approved? + + post = Topic.approved.rejected.approved.new + assert_predicate 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_with_reserved_names + class << Topic + def public_method; end + public :public_method + + def protected_method; end + protected :protected_method + + def private_method; end + private :private_method + end + + [:public_method, :protected_method, :private_method].each do |reserved_method| + assert Topic.respond_to?(reserved_method, true) + assert_called(ActiveRecord::Base.logger, :warn) do + silence_warnings { Topic.scope(reserved_method, -> { }) } + end + 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_predicate 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 + + def test_model_class_should_respond_to_extending + assert_raises OopsError do + Comment.unscoped.oops_comments.destroy_all + end + end + + def test_model_class_should_respond_to_none + assert_not_predicate Topic, :none? + Topic.delete_all + assert_predicate Topic, :none? + end + + def test_model_class_should_respond_to_one + assert_not_predicate Topic, :one? + Topic.delete_all + assert_not_predicate Topic, :one? + Topic.create! + assert_predicate Topic, :one? + 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..b1f2ffe29c --- /dev/null +++ b/activerecord/test/cases/scoping/relation_scoping_test.rb @@ -0,0 +1,426 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/author" +require "models/developer" +require "models/computer" +require "models/project" +require "models/comment" +require "models/category" +require "models/person" +require "models/reference" + +class RelationScopingTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :developers, :projects, :comments, :posts, :developers_projects + + setup do + developers(:david) + end + + def test_unscoped_breaks_caching + author = authors :mary + assert_nil author.first_post + post = FirstPost.unscoped do + author.reload.first_post + end + assert post + end + + def test_scope_breaks_caching_on_collections + author = authors :david + ids = author.reload.special_posts_with_default_scope.map(&:id) + assert_equal [1, 5, 6], ids.sort + scoped_posts = SpecialPostWithDefaultScope.unscoped do + author = authors :david + author.reload.special_posts_with_default_scope.to_a + end + assert_equal author.posts.map(&:id).sort, scoped_posts.map(&:id).sort + end + + 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_not 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_includes scoped_developers, developers(:david) + assert_not_includes scoped_developers, 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_includes scoped_developers, developers(:david) + assert_not_includes scoped_developers, 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_includes Post.find(1).comments, 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_includes Post.find(1).comments, 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_includes Post.find(1).comments, 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_not Developer.all.to_sql.include?("name = 'Jamis'"), "scope was not restored" + 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 + + def test_current_scope_does_not_pollute_sibling_subclasses + Comment.none.scoping do + assert_not_predicate SpecialComment.all, :any? + assert_not_predicate VerySpecialComment.all, :any? + assert_not_predicate SubSpecialComment.all, :any? + end + + SpecialComment.none.scoping do + assert_predicate Comment.all, :any? + assert_predicate VerySpecialComment.all, :any? + assert_not_predicate SubSpecialComment.all, :any? + end + + SubSpecialComment.none.scoping do + assert_predicate Comment.all, :any? + assert_predicate VerySpecialComment.all, :any? + assert_predicate SpecialComment.all, :any? + end + end + + def test_scoping_is_correctly_restored + Comment.unscoped do + SpecialComment.unscoped.created + end + + assert_nil Comment.send(:current_scope) + assert_nil SpecialComment.send(:current_scope) + end + + def test_scoping_respects_current_class + Comment.unscoped do + assert_equal "a comment...", Comment.all.what_are_you + assert_equal "a special comment...", SpecialComment.all.what_are_you + end + end + + def test_scoping_respects_sti_constraint + Comment.unscoped do + assert_equal comments(:greetings), Comment.find(1) + assert_raises(ActiveRecord::RecordNotFound) { SpecialComment.find(1) } + end + end + + def test_scoping_with_klass_method_works_in_the_scope_block + expected = SpecialPostWithDefaultScope.unscoped.to_a + assert_equal expected, SpecialPostWithDefaultScope.unscoped_all + end + + def test_scoping_with_query_method_works_in_the_scope_block + expected = SpecialPostWithDefaultScope.unscoped.where(author_id: 0).to_a + assert_equal expected, SpecialPostWithDefaultScope.authorless + end + + def test_circular_joins_with_scoping_does_not_crash + posts = Post.joins(comments: :post).scoping do + Post.first(10) + end + assert_equal posts, Post.joins(comments: :post).first(10) + end + + def test_circular_left_joins_with_scoping_does_not_crash + posts = Post.left_joins(comments: :post).scoping do + Post.first(10) + end + assert_equal posts, Post.left_joins(comments: :post).first(10) + end +end + +class NestedRelationScopingTest < ActiveRecord::TestCase + fixtures :authors, :author_addresses, :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|ROWNUM <= 10|FETCH FIRST 10 ROWS ONLY/, 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_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_predicate 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/secure_token_test.rb b/activerecord/test/cases/secure_token_test.rb new file mode 100644 index 0000000000..f5fa6aa302 --- /dev/null +++ b/activerecord/test/cases/secure_token_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/user" + +class SecureTokenTest < ActiveRecord::TestCase + setup do + @user = User.new + end + + def test_token_values_are_generated_for_specified_attributes_and_persisted_on_save + @user.save + assert_not_nil @user.token + assert_not_nil @user.auth_token + end + + def test_regenerating_the_secure_token + @user.save + old_token = @user.token + old_auth_token = @user.auth_token + @user.regenerate_token + @user.regenerate_auth_token + + assert_not_equal @user.token, old_token + assert_not_equal @user.auth_token, old_auth_token + end + + def test_token_value_not_overwritten_when_present + @user.token = "custom-secure-token" + @user.save + + assert_equal "custom-secure-token", @user.token + end +end diff --git a/activerecord/test/cases/serialization_test.rb b/activerecord/test/cases/serialization_test.rb new file mode 100644 index 0000000000..932780bfef --- /dev/null +++ b/activerecord/test/cases/serialization_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/contact" +require "models/topic" +require "models/book" +require "models/author" +require "models/post" + +class SerializationTest < ActiveRecord::TestCase + fixtures :books + + FORMATS = [ :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_not klazz.include_root_in_json + assert_not 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 + + def test_find_records_by_serialized_attributes_through_join + author = Author.create!(name: "David") + author.serialized_posts.create!(title: "Hello") + + assert_equal 1, Author.joins(:serialized_posts).where(name: "David", serialized_posts: { title: "Hello" }).length + 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..f6cd4f85ee --- /dev/null +++ b/activerecord/test/cases/serialized_attribute_test.rb @@ -0,0 +1,400 @@ +# frozen_string_literal: true + +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 + + # NOTE: Use a duplicate of Topic so attribute + # changes don't bleed into other tests + Topic = ::Topic.dup + + teardown do + Topic.serialize("content") + end + + def test_serialize_does_not_eagerly_load_columns + Topic.reset_column_information + assert_no_queries do + Topic.serialize(:content) + 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_assignment_with_wrong_type + Topic.serialize(:content, Hash) + assert_raise(ActiveRecord::SerializationTypeMismatch) do + Topic.new(content: "string") + 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_where_by_serialized_attribute_with_array + settings = [ "color" => "green" ] + Topic.serialize(:content, Array) + topic = Topic.create!(content: settings) + assert_equal topic, Topic.where(content: settings).take + end + + def test_where_by_serialized_attribute_with_hash + settings = { "color" => "green" } + Topic.serialize(:content, Hash) + topic = Topic.create!(content: settings) + assert_equal topic, Topic.where(content: settings).take + end + + def test_where_by_serialized_attribute_with_hash_in_array + settings = { "color" => "green" } + Topic.serialize(:content, Hash) + topic = Topic.create!(content: settings) + assert_equal topic, Topic.where(content: [settings]).take + 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 true, topic.content + end + + def test_serialized_boolean_value_false + topic = Topic.new(content: false) + assert topic.save + topic = topic.reload + assert_equal false, topic.content + 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 some_class.new("my value"), topic.content + 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_unexpected_serialized_type + Topic.serialize :content, Hash + topic = Topic.create!(content: { zomg: true }) + + Topic.serialize :content, Array + + topic.reload + error = assert_raise(ActiveRecord::SerializationTypeMismatch) do + topic.content + end + expected = "can't load `content`: was supposed to be a Array, but was a Hash. -- {:zomg=>true}" + assert_equal expected, error.to_s + end + + def test_serialized_column_should_unserialize_after_update_column + t = Topic.create(content: "first") + assert_equal("first", t.content) + + t.update_column(:content, ["second"]) + assert_equal(["second"], t.content) + assert_equal(["second"], t.reload.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) + assert_equal("second", t.reload.content) + end + + def test_nil_is_not_changed_when_serialized_with_a_class + Topic.serialize(:content, Array) + + topic = Topic.new(content: nil) + + assert_not_predicate topic, :content_changed? + end + + def test_classes_without_no_arg_constructors_are_not_supported + assert_raises(ArgumentError) do + Topic.serialize(:content, Regexp) + end + end + + def test_newly_emptied_serialized_hash_is_changed + Topic.serialize(:content, Hash) + topic = Topic.create(content: { "things" => "stuff" }) + topic.content.delete("things") + topic.save! + topic.reload + + assert_equal({}, topic.content) + end + + def test_values_cast_from_nil_are_persisted_as_nil + # This is required to fulfil the following contract, which must be universally + # true in Active Record: + # + # model.attribute = value + # assert_equal model.attribute, model.tap(&:save).reload.attribute + Topic.serialize(:content, Hash) + topic = Topic.create!(content: {}) + topic2 = Topic.create!(content: nil) + + assert_equal [topic, topic2], Topic.where(content: nil).sort_by(&:id) + end + + def test_nil_is_always_persisted_as_null + Topic.serialize(:content, Hash) + + topic = Topic.create!(content: { foo: "bar" }) + topic.update_attribute :content, nil + assert_equal [topic], Topic.where(content: nil) + end + + def test_mutation_detection_does_not_double_serialize + coder = Object.new + def coder.dump(value) + return if value.nil? + value + " encoded" + end + def coder.load(value) + return if value.nil? + value.gsub(" encoded", "") + end + type = Class.new(ActiveModel::Type::Value) do + include ActiveModel::Type::Helpers::Mutable + + def serialize(value) + return if value.nil? + value + " serialized" + end + + def deserialize(value) + return if value.nil? + value.gsub(" serialized", "") + end + end.new + model = Class.new(Topic) do + attribute :foo, type + serialize :foo, coder + end + + topic = model.create!(foo: "bar") + topic.foo + assert_not_predicate topic, :changed? + end + + def test_serialized_attribute_works_under_concurrent_initial_access + model = ::Topic.dup + + topic = model.last + topic.update group: "1" + + model.serialize :group, JSON + model.reset_column_information + + # This isn't strictly necessary for the test, but a little bit of + # knowledge of internals allows us to make failures far more likely. + model.define_singleton_method(:define_attribute) do |*args| + Thread.pass + super(*args) + end + + threads = 4.times.map do + Thread.new do + topic.reload.group + end + end + + # All the threads should retrieve the value knowing it is JSON, and + # thus decode it. If this fails, some threads will instead see the + # raw string ("1"), or raise an exception. + assert_equal [1] * threads.size, threads.map(&:value) + 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..e3c12f68fd --- /dev/null +++ b/activerecord/test/cases/statement_cache_test.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +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 + + 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.connection) + assert_equal "my book", b[0].name + b = cache.execute([ "my other 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.connection) + assert_equal b1.name, b[0].name + b = cache.execute([ b2.id ], 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 + + 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.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.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.connection) + + 3.times do + Book.create(name: "my book") + end + + additional_books = cache.execute([], Book.connection) + assert first_books != additional_books + end + + def test_unprepared_statements_dont_share_a_cache_with_prepared_statements + Book.create(name: "my book") + Book.create(name: "my other book") + + book = Book.find_by(name: "my book") + other_book = Book.connection.unprepared_statement do + Book.find_by(name: "my other book") + end + + assert_not_equal book, other_book + end + + def test_find_by_does_not_use_statement_cache_if_table_name_is_changed + book = Book.create(name: "my book") + + Book.find_by(name: book.name) # warming the statement cache. + + # changing the table name should change the query that is not cached. + Book.table_name = :birds + assert_nil Book.find_by(name: book.name) + ensure + Book.table_name = :books + end + + def test_find_does_not_use_statement_cache_if_table_name_is_changed + book = Book.create(name: "my book") + + Book.find(book.id) # warming the statement cache. + + # changing the table name should change the query that is not cached. + Book.table_name = :birds + assert_raise ActiveRecord::RecordNotFound do + Book.find(book.id) + end + ensure + Book.table_name = :books + end + end +end diff --git a/activerecord/test/cases/statement_invalid_test.rb b/activerecord/test/cases/statement_invalid_test.rb new file mode 100644 index 0000000000..16ea69c1bd --- /dev/null +++ b/activerecord/test/cases/statement_invalid_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" + +module ActiveRecord + class StatementInvalidTest < ActiveRecord::TestCase + fixtures :books + + class MockDatabaseError < StandardError + def result + 0 + end + + def error_number + 0 + end + end + + test "message contains no sql" do + sql = Book.where(author_id: 96, cover: "hard").to_sql + error = assert_raises(ActiveRecord::StatementInvalid) do + Book.connection.send(:log, sql, Book.name) do + raise MockDatabaseError + end + end + assert_not error.message.include?("SELECT") + end + + test "statement and binds are set on select" do + sql = Book.where(author_id: 96, cover: "hard").to_sql + binds = [Minitest::Mock.new, Minitest::Mock.new] + error = assert_raises(ActiveRecord::StatementInvalid) do + Book.connection.send(:log, sql, Book.name, binds) do + raise MockDatabaseError + end + end + assert_equal error.sql, sql + assert_equal error.binds, binds + 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..4457cfbd37 --- /dev/null +++ b/activerecord/test/cases/store_test.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +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, + parent_name: "Quinn", partner_name: "Dallas", + partner_birthday: "1997-11-1" + ) + 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 "reading store attributes through accessors with prefix" do + assert_equal "Quinn", @john.parent_name + assert_nil @john.parent_birthday + assert_equal "Dallas", @john.partner_name + assert_equal "1997-11-1", @john.partner_birthday + end + + test "writing store attributes through accessors with prefix" do + @john.partner_name = "River" + @john.partner_birthday = "1999-2-11" + + assert_equal "River", @john.partner_name + assert_equal "1999-2-11", @john.partner_birthday + 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_predicate @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_not_predicate @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 HashWithIndifferentAccess 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_predicate @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] + assert_equal [:color], first_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 + + test "read store attributes through accessors with default suffix" do + @john.configs[:two_factor_auth] = true + assert_equal true, @john.two_factor_auth_configs + end + + test "write store attributes through accessors with default suffix" do + @john.two_factor_auth_configs = false + assert_equal false, @john.configs[:two_factor_auth] + end + + test "read store attributes through accessors with custom suffix" do + @john.configs[:login_retry] = 3 + assert_equal 3, @john.login_retry_config + end + + test "write store attributes through accessors with custom suffix" do + @john.login_retry_config = 5 + assert_equal 5, @john.configs[:login_retry] + end + + test "read accessor without pre/suffix in the same store as other pre/suffixed accessors still works" do + @john.configs[:secret_question] = "What is your high school?" + assert_equal "What is your high school?", @john.secret_question + end + + test "write accessor without pre/suffix in the same store as other pre/suffixed accessors still works" do + @john.secret_question = "What was the Rails version when you first worked on it?" + assert_equal "What was the Rails version when you first worked on it?", @john.configs[:secret_question] + end + + test "prefix/suffix do not affect stored attributes" do + assert_equal [:secret_question, :two_factor_auth, :login_retry], Admin::User.stored_attributes[:configs] + end +end diff --git a/activerecord/test/cases/suppressor_test.rb b/activerecord/test/cases/suppressor_test.rb new file mode 100644 index 0000000000..9be5356901 --- /dev/null +++ b/activerecord/test/cases/suppressor_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/notification" +require "models/user" + +class SuppressorTest < ActiveRecord::TestCase + def test_suppresses_create + assert_no_difference -> { Notification.count } do + Notification.suppress do + Notification.create + Notification.create! + Notification.new.save + Notification.new.save! + end + end + end + + def test_suppresses_update + user = User.create! token: "asdf" + + User.suppress do + user.update token: "ghjkl" + assert_equal "asdf", user.reload.token + + user.update! token: "zxcvbnm" + assert_equal "asdf", user.reload.token + + user.token = "qwerty" + user.save + assert_equal "asdf", user.reload.token + + user.token = "uiop" + user.save! + assert_equal "asdf", user.reload.token + end + end + + def test_suppresses_create_in_callback + assert_difference -> { User.count } do + assert_no_difference -> { Notification.count } do + Notification.suppress { UserWithNotification.create! } + end + end + end + + def test_resumes_saving_after_suppression_complete + Notification.suppress { UserWithNotification.create! } + + assert_difference -> { Notification.count } do + Notification.create!(message: "New Comment") + end + end + + def test_suppresses_validations_on_create + assert_no_difference -> { Notification.count } do + Notification.suppress do + User.create + User.create! + User.new.save + User.new.save! + end + end + end + + def test_suppresses_when_nested_multiple_times + assert_no_difference -> { Notification.count } do + Notification.suppress do + Notification.suppress { } + Notification.create + Notification.create! + Notification.new.save + Notification.new.save! + end + end + 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..3fd1813d64 --- /dev/null +++ b/activerecord/test/cases/tasks/database_tasks_test.rb @@ -0,0 +1,1137 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_record/tasks/database_tasks" + +module ActiveRecord + module DatabaseTasksSetupper + def setup + @mysql_tasks, @postgresql_tasks, @sqlite_tasks = Array.new( + 3, + Class.new do + def create; end + def drop; end + def purge; end + def charset; end + def collation; end + def structure_dump(*); end + def structure_load(*); end + end.new + ) + + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def with_stubbed_new + ActiveRecord::Tasks::MySQLDatabaseTasks.stub(:new, @mysql_tasks) do + ActiveRecord::Tasks::PostgreSQLDatabaseTasks.stub(:new, @postgresql_tasks) do + ActiveRecord::Tasks::SQLiteDatabaseTasks.stub(:new, @sqlite_tasks) do + yield + end + end + end + end + end + + ADAPTERS_TASKS = { + mysql2: :mysql_tasks, + postgresql: :postgresql_tasks, + sqlite3: :sqlite_tasks + } + + class DatabaseTasksUtilsTask < ActiveRecord::TestCase + def test_raises_an_error_when_called_with_protected_environment + protected_environments = ActiveRecord::Base.protected_environments + current_env = ActiveRecord::Base.connection.migration_context.current_environment + + assert_called_on_instance_of( + ActiveRecord::MigrationContext, + :current_version, + times: 6, + returns: 1 + ) do + assert_not_includes protected_environments, current_env + # Assert no error + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + + ActiveRecord::Base.protected_environments = [current_env] + + assert_raise(ActiveRecord::ProtectedEnvironmentError) do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end + end + ensure + ActiveRecord::Base.protected_environments = protected_environments + end + + def test_raises_an_error_when_called_with_protected_environment_which_name_is_a_symbol + protected_environments = ActiveRecord::Base.protected_environments + current_env = ActiveRecord::Base.connection.migration_context.current_environment + assert_called_on_instance_of( + ActiveRecord::MigrationContext, + :current_version, + times: 6, + returns: 1 + ) do + assert_not_includes protected_environments, current_env + # Assert no error + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + + ActiveRecord::Base.protected_environments = [current_env.to_sym] + assert_raise(ActiveRecord::ProtectedEnvironmentError) do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end + end + ensure + ActiveRecord::Base.protected_environments = protected_environments + end + + def test_raises_an_error_if_no_migrations_have_been_made + ActiveRecord::InternalMetadata.stub(:table_exists?, false) do + assert_called_on_instance_of( + ActiveRecord::MigrationContext, + :current_version, + returns: 1 + ) do + assert_raise(ActiveRecord::NoEnvironmentInSchemaError) do + ActiveRecord::Tasks::DatabaseTasks.check_protected_environments! + end + end + end + end + end + + 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.stub(:new, instance) do + assert_called_with(instance, :structure_dump, ["awesome-file.sql", nil]) do + ActiveRecord::Tasks::DatabaseTasks.register_task(/foo/, klazz) + ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => :foo }, "awesome-file.sql") + end + end + 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 + with_stubbed_new do + assert_called(eval("@#{v}"), :create) do + ActiveRecord::Tasks::DatabaseTasks.create "adapter" => k + end + end + end + end + end + + class DatabaseTasksDumpSchemaCacheTest < ActiveRecord::TestCase + def test_dump_schema_cache + path = "/tmp/my_schema_cache.yml" + ActiveRecord::Tasks::DatabaseTasks.dump_schema_cache(ActiveRecord::Base.connection, path) + assert File.file?(path) + ensure + ActiveRecord::Base.clear_cache! + FileUtils.rm_rf(path) + end + end + + class DatabaseTasksCreateAllTest < ActiveRecord::TestCase + def setup + @configurations = { "development" => { "database" => "my-db" } } + + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_ignores_configurations_without_databases + @configurations["development"]["database"] = nil + + with_stubbed_configurations_establish_connection do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end + end + + def test_ignores_remote_databases + @configurations["development"]["host"] = "my.server.tld" + + with_stubbed_configurations_establish_connection do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end + end + + def test_warning_for_remote_databases + @configurations["development"]["host"] = "my.server.tld" + + with_stubbed_configurations_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.create_all + + assert_match "This task only modifies local databases. my-db is on a remote host.", + $stderr.string + end + end + + def test_creates_configurations_with_local_ip + @configurations["development"]["host"] = "127.0.0.1" + + with_stubbed_configurations_establish_connection do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end + end + + def test_creates_configurations_with_local_host + @configurations["development"]["host"] = "localhost" + + with_stubbed_configurations_establish_connection do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end + end + + def test_creates_configurations_with_blank_hosts + @configurations["development"]["host"] = nil + + with_stubbed_configurations_establish_connection do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :create) do + ActiveRecord::Tasks::DatabaseTasks.create_all + end + end + end + + private + def with_stubbed_configurations_establish_connection + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + # To refrain from connecting to a newly created empty DB in + # sqlite3_mem tests + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksCreateCurrentTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "database" => "dev-db" }, + "test" => { "database" => "test-db" }, + "production" => { "url" => "abstract://prod-db-host/prod-db" } + } + end + + def test_creates_current_environment_database + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + ["database" => "test-db"], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_creates_current_environment_database_with_url + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_creates_test_and_development_databases_when_env_was_not_specified + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_creates_test_and_development_databases_when_rails_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ], + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + def test_establishes_connection_for_the_given_environments + ActiveRecord::Tasks::DatabaseTasks.stub(:create, nil) do + assert_called_with(ActiveRecord::Base, :establish_connection, [:development]) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + private + def with_stubbed_configurations_establish_connection + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksCreateCurrentThreeTierTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, + "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } + } + end + + def test_creates_current_environment_database + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_creates_current_environment_database_with_url + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_creates_test_and_development_databases_when_env_was_not_specified + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_creates_test_and_development_databases_when_rails_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations_establish_connection do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :create, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + def test_establishes_connection_for_the_given_environments_config + ActiveRecord::Tasks::DatabaseTasks.stub(:create, nil) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [:development] + ) do + ActiveRecord::Tasks::DatabaseTasks.create_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + private + def with_stubbed_configurations_establish_connection + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + ActiveRecord::Base.connection_handler.stub(:establish_connection, nil) do + yield + end + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksDropTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_drop") do + with_stubbed_new do + assert_called(eval("@#{v}"), :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop "adapter" => k + end + end + end + end + end + + class DatabaseTasksDropAllTest < ActiveRecord::TestCase + def setup + @configurations = { development: { "database" => "my-db" } } + + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_ignores_configurations_without_databases + @configurations[:development]["database"] = nil + + with_stubbed_configurations do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end + end + + def test_ignores_remote_databases + @configurations[:development]["host"] = "my.server.tld" + + with_stubbed_configurations do + assert_not_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end + end + + def test_warning_for_remote_databases + @configurations[:development]["host"] = "my.server.tld" + + with_stubbed_configurations do + ActiveRecord::Tasks::DatabaseTasks.drop_all + + assert_match "This task only modifies local databases. my-db is on a remote host.", + $stderr.string + end + end + + def test_drops_configurations_with_local_ip + @configurations[:development]["host"] = "127.0.0.1" + + with_stubbed_configurations do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end + end + + def test_drops_configurations_with_local_host + @configurations[:development]["host"] = "localhost" + + with_stubbed_configurations do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end + end + + def test_drops_configurations_with_blank_hosts + @configurations[:development]["host"] = nil + + with_stubbed_configurations do + assert_called(ActiveRecord::Tasks::DatabaseTasks, :drop) do + ActiveRecord::Tasks::DatabaseTasks.drop_all + end + end + end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksDropCurrentTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "database" => "dev-db" }, + "test" => { "database" => "test-db" }, + "production" => { "url" => "abstract://prod-db-host/prod-db" } + } + end + + def test_drops_current_environment_database + with_stubbed_configurations do + assert_called_with(ActiveRecord::Tasks::DatabaseTasks, :drop, + ["database" => "test-db"]) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_drops_current_environment_database_with_url + with_stubbed_configurations do + assert_called_with(ActiveRecord::Tasks::DatabaseTasks, :drop, + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"]) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_drops_test_and_development_databases_when_env_was_not_specified + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_drops_testand_development_databases_when_rails_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksDropCurrentThreeTierTest < ActiveRecord::TestCase + def setup + @configurations = { + "development" => { "primary" => { "database" => "dev-db" }, "secondary" => { "database" => "secondary-dev-db" } }, + "test" => { "primary" => { "database" => "test-db" }, "secondary" => { "database" => "secondary-test-db" } }, + "production" => { "primary" => { "url" => "abstract://prod-db-host/prod-db" }, "secondary" => { "url" => "abstract://secondary-prod-db-host/secondary-prod-db" } } + } + end + + def test_drops_current_environment_database + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("test") + ) + end + end + end + + def test_drops_current_environment_database_with_url + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["adapter" => "abstract", "database" => "prod-db", "host" => "prod-db-host"], + ["adapter" => "abstract", "database" => "secondary-prod-db", "host" => "secondary-prod-db-host"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("production") + ) + end + end + end + + def test_drops_test_and_development_databases_when_env_was_not_specified + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + end + + def test_drops_testand_development_databases_when_rails_env_is_development + old_env = ENV["RAILS_ENV"] + ENV["RAILS_ENV"] = "development" + + with_stubbed_configurations do + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :drop, + [ + ["database" => "dev-db"], + ["database" => "secondary-dev-db"], + ["database" => "test-db"], + ["database" => "secondary-test-db"] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop_current( + ActiveSupport::StringInquirer.new("development") + ) + end + end + ensure + ENV["RAILS_ENV"] = old_env + end + + private + def with_stubbed_configurations + old_configurations = ActiveRecord::Base.configurations + ActiveRecord::Base.configurations = @configurations + + yield + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + if current_adapter?(:SQLite3Adapter) && !in_memory_db? + class DatabaseTasksMigrationTestCase < ActiveRecord::TestCase + self.use_transactional_tests = false + + # Use a memory db here to avoid having to rollback at the end + setup do + migrations_path = MIGRATIONS_ROOT + "/valid" + file = ActiveRecord::Base.connection.raw_connection.filename + @conn = ActiveRecord::Base.establish_connection adapter: "sqlite3", + database: ":memory:", migrations_paths: migrations_path + source_db = SQLite3::Database.new file + dest_db = ActiveRecord::Base.connection.raw_connection + backup = SQLite3::Backup.new(dest_db, "main", source_db, "main") + backup.step(-1) + backup.finish + end + + teardown do + @conn.release_connection if @conn + ActiveRecord::Base.establish_connection :arunit + end + end + + class DatabaseTasksMigrateTest < DatabaseTasksMigrationTestCase + def test_migrate_set_and_unset_verbose_and_version_env_vars + verbose, version = ENV["VERBOSE"], ENV["VERSION"] + ENV["VERSION"] = "2" + ENV["VERBOSE"] = "false" + + # run down migration because it was already run on copied db + assert_empty capture_migration_output + + ENV.delete("VERSION") + ENV.delete("VERBOSE") + + # re-run up migration + assert_includes capture_migration_output, "migrating" + ensure + ENV["VERBOSE"], ENV["VERSION"] = verbose, version + end + + def test_migrate_set_and_unset_empty_values_for_verbose_and_version_env_vars + verbose, version = ENV["VERBOSE"], ENV["VERSION"] + + ENV["VERSION"] = "2" + ENV["VERBOSE"] = "false" + + # run down migration because it was already run on copied db + assert_empty capture_migration_output + + ENV["VERBOSE"] = "" + ENV["VERSION"] = "" + + # re-run up migration + assert_includes capture_migration_output, "migrating" + ensure + ENV["VERBOSE"], ENV["VERSION"] = verbose, version + end + + def test_migrate_set_and_unset_nonsense_values_for_verbose_and_version_env_vars + verbose, version = ENV["VERBOSE"], ENV["VERSION"] + + # run down migration because it was already run on copied db + ENV["VERSION"] = "2" + ENV["VERBOSE"] = "false" + + assert_empty capture_migration_output + + ENV["VERBOSE"] = "yes" + ENV["VERSION"] = "2" + + # run no migration because 2 was already run + assert_empty capture_migration_output + ensure + ENV["VERBOSE"], ENV["VERSION"] = verbose, version + end + + private + def capture_migration_output + capture(:stdout) do + ActiveRecord::Tasks::DatabaseTasks.migrate + end + end + end + + class DatabaseTasksMigrateStatusTest < DatabaseTasksMigrationTestCase + def test_migrate_status_table + ActiveRecord::SchemaMigration.create_table + output = capture_migration_status + assert_match(/database: :memory:/, output) + assert_match(/down 001 Valid people have last names/, output) + assert_match(/down 002 We need reminders/, output) + assert_match(/down 003 Innocent jointable/, output) + ActiveRecord::SchemaMigration.drop_table + end + + private + + def capture_migration_status + capture(:stdout) do + ActiveRecord::Tasks::DatabaseTasks.migrate_status + end + end + end + end + + class DatabaseTasksMigrateErrorTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + def test_migrate_raise_error_on_invalid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "unknown" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0 " + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1." + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_name" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_match(/Invalid format of target version/, e.message) + ensure + ENV["VERSION"] = version + end + + def test_migrate_raise_error_on_failed_check_target_version + ActiveRecord::Tasks::DatabaseTasks.stub(:check_target_version, -> { raise "foo" }) do + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.migrate } + assert_equal "foo", e.message + end + end + + def test_migrate_clears_schema_cache_afterward + assert_called(ActiveRecord::Base, :clear_cache!) do + ActiveRecord::Tasks::DatabaseTasks.migrate + end + end + end + + class DatabaseTasksPurgeTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_purge") do + with_stubbed_new do + assert_called(eval("@#{v}"), :purge) do + ActiveRecord::Tasks::DatabaseTasks.purge "adapter" => k + end + end + end + end + end + + class DatabaseTasksPurgeCurrentTest < ActiveRecord::TestCase + def test_purges_current_environment_database + old_configurations = ActiveRecord::Base.configurations + configurations = { + "development" => { "database" => "dev-db" }, + "test" => { "database" => "test-db" }, + "production" => { "database" => "prod-db" } + } + + ActiveRecord::Base.configurations = configurations + + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :purge, + ["database" => "prod-db"] + ) do + assert_called_with(ActiveRecord::Base, :establish_connection, [:production]) do + ActiveRecord::Tasks::DatabaseTasks.purge_current("production") + end + end + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksPurgeAllTest < ActiveRecord::TestCase + def test_purge_all_local_configurations + old_configurations = ActiveRecord::Base.configurations + configurations = { development: { "database" => "my-db" } } + ActiveRecord::Base.configurations = configurations + + assert_called_with( + ActiveRecord::Tasks::DatabaseTasks, + :purge, + ["database" => "my-db"] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge_all + end + ensure + ActiveRecord::Base.configurations = old_configurations + end + end + + class DatabaseTasksCharsetTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_charset") do + with_stubbed_new do + assert_called(eval("@#{v}"), :charset) do + ActiveRecord::Tasks::DatabaseTasks.charset "adapter" => k + end + end + end + end + end + + class DatabaseTasksCollationTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_collation") do + with_stubbed_new do + assert_called(eval("@#{v}"), :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation "adapter" => k + end + end + end + end + end + + class DatabaseTaskTargetVersionTest < ActiveRecord::TestCase + def test_target_version_returns_nil_if_version_does_not_exist + version = ENV.delete("VERSION") + assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + + def test_target_version_returns_nil_if_version_is_empty + version = ENV["VERSION"] + + ENV["VERSION"] = "" + assert_nil ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + + def test_target_version_returns_converted_to_integer_env_version_if_version_exists + version = ENV["VERSION"] + + ENV["VERSION"] = "0" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + + ENV["VERSION"] = "42" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + + ENV["VERSION"] = "042" + assert_equal ENV["VERSION"].to_i, ActiveRecord::Tasks::DatabaseTasks.target_version + ensure + ENV["VERSION"] = version + end + end + + class DatabaseTaskCheckTargetVersionTest < ActiveRecord::TestCase + def test_check_target_version_does_not_raise_error_on_empty_version + version = ENV["VERSION"] + ENV["VERSION"] = "" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_does_not_raise_error_if_version_is_not_setted + version = ENV.delete("VERSION") + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_raises_error_on_invalid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "unknown" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1.1.11" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "0 " + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1." + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + + ENV["VERSION"] = "1_name" + e = assert_raise(RuntimeError) { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + assert_match(/Invalid format of target version/, e.message) + ensure + ENV["VERSION"] = version + end + + def test_check_target_version_does_not_raise_error_on_valid_version_format + version = ENV["VERSION"] + + ENV["VERSION"] = "0" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "1" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "001" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + + ENV["VERSION"] = "001_name.rb" + assert_nothing_raised { ActiveRecord::Tasks::DatabaseTasks.check_target_version } + ensure + ENV["VERSION"] = version + end + end + + class DatabaseTasksStructureDumpTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_structure_dump") do + with_stubbed_new do + assert_called_with( + eval("@#{v}"), :structure_dump, + ["awesome-file.sql", nil] + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump({ "adapter" => k }, "awesome-file.sql") + end + end + end + end + end + + class DatabaseTasksStructureLoadTest < ActiveRecord::TestCase + include DatabaseTasksSetupper + + ADAPTERS_TASKS.each do |k, v| + define_method("test_#{k}_structure_load") do + with_stubbed_new do + assert_called_with( + eval("@#{v}"), + :structure_load, + ["awesome-file.sql", nil] + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load({ "adapter" => k }, "awesome-file.sql") + end + end + end + end + end + + class DatabaseTasksCheckSchemaFileTest < ActiveRecord::TestCase + def test_check_schema_file + assert_called_with(Kernel, :abort, [/awesome-file.sql/]) do + ActiveRecord::Tasks::DatabaseTasks.check_schema_file("awesome-file.sql") + end + end + end + + class DatabaseTasksCheckSchemaFileDefaultsTest < ActiveRecord::TestCase + def test_check_schema_file_defaults + ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/tmp") do + assert_equal "/tmp/schema.rb", ActiveRecord::Tasks::DatabaseTasks.schema_file + end + end + end + + class DatabaseTasksCheckSchemaFileSpecifiedFormatsTest < ActiveRecord::TestCase + { ruby: "schema.rb", sql: "structure.sql" }.each_pair do |fmt, filename| + define_method("test_check_schema_file_for_#{fmt}_format") do + ActiveRecord::Tasks::DatabaseTasks.stub(:db_dir, "/tmp") do + assert_equal "/tmp/#{filename}", ActiveRecord::Tasks::DatabaseTasks.schema_file(fmt) + end + end + 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..552e623fd4 --- /dev/null +++ b/activerecord/test/cases/tasks/mysql_rake_test.rb @@ -0,0 +1,409 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_record/tasks/database_tasks" + +if current_adapter?(:Mysql2Adapter) + module ActiveRecord + class MysqlDBCreateTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def create_database(*); end }.new + @configuration = { + "adapter" => "mysql2", + "database" => "my-app-db" + } + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_establishes_connection_without_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ "adapter" => "mysql2", "database" => nil ], + [ "adapter" => "mysql2", "database" => "my-app-db" ], + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + + def test_creates_database_with_no_default_options + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :create_database, ["my-app-db", {}]) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + + def test_creates_database_with_given_encoding + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :create_database, ["my-app-db", charset: "latin1"]) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("encoding" => "latin1") + end + end + end + + def test_creates_database_with_given_collation + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + ["my-app-db", collation: "latin1_swedish_ci"] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration.merge("collation" => "latin1_swedish_ci") + end + end + end + + def test_establishes_connection_to_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + ["adapter" => "mysql2", "database" => nil], + [@configuration] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + + def test_when_database_created_successfully_outputs_info_to_stdout + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + + assert_equal "Created database 'my-app-db'\n", $stdout.string + end + end + + def test_create_when_database_exists_outputs_info_to_stderr + with_stubbed_connection_establish_connection do + ActiveRecord::Base.connection.stub( + :create_database, + proc { raise ActiveRecord::Tasks::DatabaseAlreadyExists } + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + + assert_equal "Database 'my-app-db' already exists\n", $stderr.string + end + end + end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end + end + + class MysqlDBCreateWithInvalidPermissionsTest < ActiveRecord::TestCase + def setup + @error = Mysql2::Error.new("Invalid permissions") + @configuration = { + "adapter" => "mysql2", + "database" => "my-app-db", + "username" => "pat", + "password" => "wossname" + } + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_raises_error + ActiveRecord::Base.stub(:establish_connection, -> * { raise @error }) do + assert_raises(Mysql2::Error, "Invalid permissions") do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + end + + class MySQLDBDropTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def drop_database(name); end }.new + @configuration = { + "adapter" => "mysql2", + "database" => "my-app-db" + } + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_establishes_connection_to_mysql_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [@configuration] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end + end + + def test_drops_database + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :drop_database, ["my-app-db"]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end + end + + def test_when_database_dropped_successfully_outputs_info_to_stdout + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + + assert_equal "Dropped database 'my-app-db'\n", $stdout.string + end + end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end + end + + class MySQLPurgeTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def recreate_database(*); end }.new + @configuration = { + "adapter" => "mysql2", + "database" => "test-db" + } + end + + def test_establishes_connection_to_the_appropriate_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [@configuration] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + + def test_recreates_database_with_no_default_options + with_stubbed_connection_establish_connection do + assert_called_with(@connection, :recreate_database, ["test-db", {}]) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + + def test_recreates_database_with_the_given_options + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :recreate_database, + ["test-db", charset: "latin", collation: "latin1_swedish_ci"] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration.merge( + "encoding" => "latin", "collation" => "latin1_swedish_ci") + end + end + end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end + end + + class MysqlDBCharsetTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def charset; end }.new + @configuration = { + "adapter" => "mysql2", + "database" => "my-app-db" + } + end + + def test_db_retrieves_charset + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :charset) do + ActiveRecord::Tasks::DatabaseTasks.charset @configuration + end + end + end + end + + class MysqlDBCollationTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def collation; end }.new + @configuration = { + "adapter" => "mysql2", + "database" => "my-app-db" + } + end + + def test_db_retrieves_collation + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end + end + end + + class MySQLStructureDumpTest < ActiveRecord::TestCase + def setup + @configuration = { + "adapter" => "mysql2", + "database" => "test-db" + } + end + + def test_structure_dump + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + end + + def test_structure_dump_with_extra_flags + filename = "awesome-file.sql" + expected_command = ["mysqldump", "--noop", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"] + + assert_called_with(Kernel, :system, expected_command, returns: true) do + with_structure_dump_flags(["--noop"]) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + end + end + + def test_structure_dump_with_ignore_tables + filename = "awesome-file.sql" + ActiveRecord::SchemaDumper.stub(:ignore_tables, ["foo", "bar"]) do + assert_called_with( + Kernel, + :system, + ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "--ignore-table=test-db.foo", "--ignore-table=test-db.bar", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + end + end + + def test_warn_when_external_structure_dump_command_execution_fails + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["mysqldump", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: false + ) do + e = assert_raise(RuntimeError) { + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + } + assert_match(/^failed to execute: `mysqldump`$/, e.message) + end + end + + def test_structure_dump_with_port_number + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["mysqldump", "--port=10000", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump( + @configuration.merge("port" => 10000), + filename) + end + end + + def test_structure_dump_with_ssl + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["mysqldump", "--ssl-ca=ca.crt", "--result-file", filename, "--no-data", "--routines", "--skip-comments", "test-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump( + @configuration.merge("sslca" => "ca.crt"), + filename) + end + end + + private + def with_structure_dump_flags(flags) + old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags + yield + ensure + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old + end + end + + class MySQLStructureLoadTest < ActiveRecord::TestCase + def setup + @configuration = { + "adapter" => "mysql2", + "database" => "test-db" + } + end + + def test_structure_load + filename = "awesome-file.sql" + expected_command = ["mysql", "--noop", "--execute", %{SET FOREIGN_KEY_CHECKS = 0; SOURCE #{filename}; SET FOREIGN_KEY_CHECKS = 1}, "--database", "test-db"] + + assert_called_with(Kernel, :system, expected_command, returns: true) do + with_structure_load_flags(["--noop"]) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + end + end + + private + def with_structure_load_flags(flags) + old = ActiveRecord::Tasks::DatabaseTasks.structure_load_flags + ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = flags + yield + ensure + ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = old + 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..065ba7734c --- /dev/null +++ b/activerecord/test/cases/tasks/postgresql_rake_test.rb @@ -0,0 +1,539 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_record/tasks/database_tasks" + +if current_adapter?(:PostgreSQLAdapter) + module ActiveRecord + class PostgreSQLDBCreateTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def create_database(*); end }.new + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_establishes_connection_to_postgresql_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + "adapter" => "postgresql", + "database" => "my-app-db" + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + + def test_creates_database_with_default_encoding + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + ["my-app-db", @configuration.merge("encoding" => "utf8")] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + + def test_creates_database_with_given_encoding + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + ["my-app-db", @configuration.merge("encoding" => "latin")] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration. + merge("encoding" => "latin") + end + end + end + + def test_creates_database_with_given_collation_and_ctype + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :create_database, + [ + "my-app-db", + @configuration.merge( + "encoding" => "utf8", + "collation" => "ja_JP.UTF8", + "ctype" => "ja_JP.UTF8" + ) + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration. + merge("collation" => "ja_JP.UTF8", "ctype" => "ja_JP.UTF8") + end + end + end + + def test_establishes_connection_to_new_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + @configuration + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + end + end + end + + def test_db_create_with_error_prints_message + ActiveRecord::Base.stub(:connection, @connection) do + ActiveRecord::Base.stub(:establish_connection, -> * { raise Exception }) do + assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration } + assert_match "Couldn't create '#{@configuration['database']}' database. Please check your configuration.", $stderr.string + end + end + end + + def test_when_database_created_successfully_outputs_info_to_stdout + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + + assert_equal "Created database 'my-app-db'\n", $stdout.string + end + end + + def test_create_when_database_exists_outputs_info_to_stderr + with_stubbed_connection_establish_connection do + ActiveRecord::Base.connection.stub( + :create_database, + proc { raise ActiveRecord::Tasks::DatabaseAlreadyExists } + ) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration + + assert_equal "Database 'my-app-db' already exists\n", $stderr.string + end + end + end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:connection, @connection) do + ActiveRecord::Base.stub(:establish_connection, nil) do + yield + end + end + end + end + + class PostgreSQLDBDropTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def drop_database(*); end }.new + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_establishes_connection_to_postgresql_database + ActiveRecord::Base.stub(:connection, @connection) do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end + end + + def test_drops_database + with_stubbed_connection_establish_connection do + assert_called_with( + @connection, + :drop_database, + ["my-app-db"] + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + end + end + end + + def test_when_database_dropped_successfully_outputs_info_to_stdout + with_stubbed_connection_establish_connection do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration + + assert_equal "Dropped database 'my-app-db'\n", $stdout.string + end + end + + private + + def with_stubbed_connection_establish_connection + ActiveRecord::Base.stub(:connection, @connection) do + ActiveRecord::Base.stub(:establish_connection, nil) do + yield + end + end + end + end + + class PostgreSQLPurgeTest < ActiveRecord::TestCase + def setup + @connection = Class.new do + def create_database(*); end + def drop_database(*); end + end.new + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + end + + def test_clears_active_connections + with_stubbed_connection do + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called(ActiveRecord::Base, :clear_active_connections!) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + end + + def test_establishes_connection_to_postgresql_database + with_stubbed_connection do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + "adapter" => "postgresql", + "database" => "my-app-db" + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + + def test_drops_database + with_stubbed_connection do + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called_with(@connection, :drop_database, ["my-app-db"]) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + end + + def test_creates_database + with_stubbed_connection do + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called_with( + @connection, + :create_database, + ["my-app-db", @configuration.merge("encoding" => "utf8")] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + end + + def test_establishes_connection + with_stubbed_connection do + assert_called_with( + ActiveRecord::Base, + :establish_connection, + [ + [ + "adapter" => "postgresql", + "database" => "postgres", + "schema_search_path" => "public" + ], + [ + @configuration + ] + ] + ) do + ActiveRecord::Tasks::DatabaseTasks.purge @configuration + end + end + end + + private + + def with_stubbed_connection + ActiveRecord::Base.stub(:connection, @connection) do + yield + end + end + end + + class PostgreSQLDBCharsetTest < ActiveRecord::TestCase + def setup + @connection = Class.new do + def create_database(*); end + def encoding; end + end.new + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + end + + def test_db_retrieves_charset + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :encoding) do + ActiveRecord::Tasks::DatabaseTasks.charset @configuration + end + end + end + end + + class PostgreSQLDBCollationTest < ActiveRecord::TestCase + def setup + @connection = Class.new { def collation; end }.new + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + end + + def test_db_retrieves_collation + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :collation) do + ActiveRecord::Tasks::DatabaseTasks.collation @configuration + end + end + end + end + + class PostgreSQLStructureDumpTest < ActiveRecord::TestCase + def setup + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + @filename = "/tmp/awesome-file.sql" + FileUtils.touch(@filename) + end + + def teardown + FileUtils.rm_f(@filename) + end + + def test_structure_dump + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + + def test_structure_dump_header_comments_removed + Kernel.stub(:system, true) do + File.write(@filename, "-- header comment\n\n-- more header comment\n statement \n-- lower comment\n") + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + + assert_equal [" statement \n", "-- lower comment\n"], File.readlines(@filename).first(2) + end + end + + def test_structure_dump_with_extra_flags + expected_command = ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--noop", "my-app-db"] + + assert_called_with(Kernel, :system, expected_command, returns: true) do + with_structure_dump_flags(["--noop"]) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + end + + def test_structure_dump_with_ignore_tables + assert_called( + ActiveRecord::SchemaDumper, + :ignore_tables, + returns: ["foo", "bar"] + ) do + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "-T", "foo", "-T", "bar", "my-app-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + end + + def test_structure_dump_with_schema_search_path + @configuration["schema_search_path"] = "foo,bar" + + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db"], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + + def test_structure_dump_with_schema_search_path_and_dump_schemas_all + @configuration["schema_search_path"] = "foo,bar" + + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "my-app-db"], + returns: true + ) do + with_dump_schemas(:all) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + end + + def test_structure_dump_with_dump_schemas_string + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", @filename, "--schema=foo", "--schema=bar", "my-app-db"], + returns: true + ) do + with_dump_schemas("foo,bar") do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, @filename) + end + end + end + + def test_structure_dump_execution_fails + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["pg_dump", "-s", "-x", "-O", "-f", filename, "my-app-db"], + returns: nil + ) do + e = assert_raise(RuntimeError) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename) + end + assert_match("failed to execute:", e.message) + end + end + + private + def with_dump_schemas(value, &block) + old_dump_schemas = ActiveRecord::Base.dump_schemas + ActiveRecord::Base.dump_schemas = value + yield + ensure + ActiveRecord::Base.dump_schemas = old_dump_schemas + end + + def with_structure_dump_flags(flags) + old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags + yield + ensure + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old + end + end + + class PostgreSQLStructureLoadTest < ActiveRecord::TestCase + def setup + @configuration = { + "adapter" => "postgresql", + "database" => "my-app-db" + } + end + + def test_structure_load + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, @configuration["database"]], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + end + + def test_structure_load_with_extra_flags + filename = "awesome-file.sql" + expected_command = ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, "--noop", @configuration["database"]] + + assert_called_with(Kernel, :system, expected_command, returns: true) do + with_structure_load_flags(["--noop"]) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + end + end + + def test_structure_load_accepts_path_with_spaces + filename = "awesome file.sql" + assert_called_with( + Kernel, + :system, + ["psql", "-v", "ON_ERROR_STOP=1", "-q", "-X", "-f", filename, @configuration["database"]], + returns: true + ) do + ActiveRecord::Tasks::DatabaseTasks.structure_load(@configuration, filename) + end + end + + private + def with_structure_load_flags(flags) + old = ActiveRecord::Tasks::DatabaseTasks.structure_load_flags + ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = flags + yield + ensure + ActiveRecord::Tasks::DatabaseTasks.structure_load_flags = old + 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..c1092b97c1 --- /dev/null +++ b/activerecord/test/cases/tasks/sqlite_rake_test.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_record/tasks/database_tasks" +require "pathname" + +if current_adapter?(:SQLite3Adapter) + module ActiveRecord + class SqliteDBCreateTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @configuration = { + "adapter" => "sqlite3", + "database" => @database + } + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_db_checks_database_exists + ActiveRecord::Base.stub(:establish_connection, nil) do + assert_called_with(File, :exist?, [@database], returns: false) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end + end + end + + def test_when_db_created_successfully_outputs_info_to_stdout + ActiveRecord::Base.stub(:establish_connection, nil) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + + assert_equal "Created database '#{@database}'\n", $stdout.string + end + end + + def test_db_create_when_file_exists + File.stub(:exist?, true) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + + assert_equal "Database '#{@database}' already exists\n", $stderr.string + end + end + + def test_db_create_with_file_does_nothing + File.stub(:exist?, true) do + assert_not_called(ActiveRecord::Base, :establish_connection) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end + end + end + + def test_db_create_establishes_a_connection + assert_called_with(ActiveRecord::Base, :establish_connection, [@configuration]) do + ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" + end + end + + def test_db_create_with_error_prints_message + ActiveRecord::Base.stub(:establish_connection, proc { raise Exception }) do + assert_raises(Exception) { ActiveRecord::Tasks::DatabaseTasks.create @configuration, "/rails/root" } + assert_match "Couldn't create '#{@configuration['database']}' database. Please check your configuration.", $stderr.string + end + end + end + + class SqliteDBDropTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @configuration = { + "adapter" => "sqlite3", + "database" => @database + } + @path = Class.new do + def to_s; "/absolute/path" end + def absolute?; true end + end.new + + $stdout, @original_stdout = StringIO.new, $stdout + $stderr, @original_stderr = StringIO.new, $stderr + end + + def teardown + $stdout, $stderr = @original_stdout, @original_stderr + end + + def test_creates_path_from_database + assert_called_with(Pathname, :new, [@database], returns: @path) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end + + def test_removes_file_with_absolute_path + Pathname.stub(:new, @path) do + assert_called_with(FileUtils, :rm, ["/absolute/path"]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end + end + + def test_generates_absolute_path_with_given_root + Pathname.stub(:new, @path) do + @path.stub(:absolute?, false) do + assert_called_with(File, :join, ["/rails/root", @path], + returns: "/former/relative/path" + ) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end + end + end + + def test_removes_file_with_relative_path + File.stub(:join, "/former/relative/path") do + @path.stub(:absolute?, false) do + assert_called_with(FileUtils, :rm, ["/former/relative/path"]) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + end + end + end + end + + def test_when_db_dropped_successfully_outputs_info_to_stdout + FileUtils.stub(:rm, nil) do + ActiveRecord::Tasks::DatabaseTasks.drop @configuration, "/rails/root" + + assert_equal "Dropped database '#{@database}'\n", $stdout.string + end + end + end + + class SqliteDBCharsetTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @connection = Class.new { def encoding; end }.new + @configuration = { + "adapter" => "sqlite3", + "database" => @database + } + end + + def test_db_retrieves_charset + ActiveRecord::Base.stub(:connection, @connection) do + assert_called(@connection, :encoding) do + ActiveRecord::Tasks::DatabaseTasks.charset @configuration, "/rails/root" + end + end + end + end + + class SqliteDBCollationTest < ActiveRecord::TestCase + def setup + @database = "db_create.sqlite3" + @configuration = { + "adapter" => "sqlite3", + "database" => @database + } + 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 + } + + `sqlite3 #{@database} 'CREATE TABLE bar(id INTEGER)'` + `sqlite3 #{@database} 'CREATE TABLE foo(id INTEGER)'` + 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) + assert_match(/CREATE TABLE foo/, File.read(filename)) + assert_match(/CREATE TABLE bar/, File.read(filename)) + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + + def test_structure_dump_with_ignore_tables + dbfile = @database + filename = "awesome-file.sql" + assert_called(ActiveRecord::SchemaDumper, :ignore_tables, returns: ["foo"]) do + ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") + end + assert File.exist?(dbfile) + assert File.exist?(filename) + assert_match(/bar/, File.read(filename)) + assert_no_match(/foo/, File.read(filename)) + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + + def test_structure_dump_execution_fails + dbfile = @database + filename = "awesome-file.sql" + assert_called_with( + Kernel, + :system, + ["sqlite3", "--noop", "db_create.sqlite3", ".schema", out: "awesome-file.sql"], + returns: nil + ) do + e = assert_raise(RuntimeError) do + with_structure_dump_flags(["--noop"]) do + quietly { ActiveRecord::Tasks::DatabaseTasks.structure_dump(@configuration, filename, "/rails/root") } + end + end + assert_match("failed to execute:", e.message) + end + ensure + FileUtils.rm_f(filename) + FileUtils.rm_f(dbfile) + end + + private + def with_structure_dump_flags(flags) + old = ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags + yield + ensure + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = old + 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..5b25432dc0 --- /dev/null +++ b/activerecord/test/cases/test_case.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/testing/autorun" +require "active_support/testing/method_call_assertions" +require "active_support/testing/stream" +require "active_record/fixtures" + +require "cases/validations_repair_helper" + +module ActiveRecord + # = Active Record Test Case + # + # Defines some test assertions to test against SQL queries. + class TestCase < ActiveSupport::TestCase #:nodoc: + include ActiveSupport::Testing::MethodCallAssertions + include ActiveSupport::Testing::Stream + include ActiveRecord::TestFixtures + include ActiveRecord::ValidationsRepairHelper + + self.fixture_path = FIXTURES_ROOT + self.use_instantiated_fixtures = false + self.use_transactional_tests = true + + def create_fixtures(*fixture_set_names, &block) + ActiveRecord::FixtureSet.create_fixtures(ActiveRecord::TestCase.fixture_path, fixture_set_names, fixture_class_names, &block) + end + + def teardown + SQLCounter.clear_log + end + + def capture_sql + ActiveRecord::Base.connection.materialize_transactions + 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(&: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 } + ActiveRecord::Base.connection.materialize_transactions + 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 PostgreSQLTestCase < TestCase + def self.run(*args) + super if current_adapter?(:PostgreSQLAdapter) + end + end + + class Mysql2TestCase < TestCase + def self.run(*args) + super if current_adapter?(:Mysql2Adapter) + end + end + + class SQLite3TestCase < TestCase + def self.run(*args) + super if current_adapter?(:SQLite3Adapter) + end + end + + class SQLCounter + class << self + attr_accessor :ignored_sql, :log, :log_all + def clear_log; self.log = []; self.log_all = []; end + end + + 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, /^\s*select .* from all_sequences/im] + mysql_ignored = [/^SHOW FULL TABLES/i, /^SHOW FULL FIELDS/, /^SHOW CREATE TABLE /i, /^SHOW VARIABLES /, /^\s*SELECT (?:column_name|table_name)\b.*\bFROM information_schema\.(?:key_column_usage|tables)\b/im] + postgresql_ignored = [/^\s*select\b.*\bfrom\b.*pg_namespace\b/im, /^\s*select tablename\b.*from pg_tables\b/im, /^\s*select\b.*\battname\b.*\bfrom\b.*\bpg_attribute\b/im, /^SHOW search_path/i, /^\s*SELECT\b.*::regtype::oid\b/im] + sqlite3_ignored = [/^\s*SELECT name\b.*\bFROM sqlite_master/im, /^\s*SELECT sql\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) + return if values[:cached] + + sql = values[:sql] + self.class.log_all << sql + self.class.log << sql unless ignore.match?(sql) + end + end + + ActiveSupport::Notifications.subscribe("sql.active_record", SQLCounter.new) +end diff --git a/activerecord/test/cases/test_fixtures_test.rb b/activerecord/test/cases/test_fixtures_test.rb new file mode 100644 index 0000000000..4411410eda --- /dev/null +++ b/activerecord/test/cases/test_fixtures_test.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "cases/helper" + +class TestFixturesTest < ActiveRecord::TestCase + setup do + @klass = Class.new + @klass.include(ActiveRecord::TestFixtures) + end + + def test_use_transactional_tests_defaults_to_true + assert_equal true, @klass.use_transactional_tests + end + + def test_use_transactional_tests_can_be_overridden + @klass.use_transactional_tests = "foobar" + + assert_equal "foobar", @klass.use_transactional_tests + end +end diff --git a/activerecord/test/cases/time_precision_test.rb b/activerecord/test/cases/time_precision_test.rb new file mode 100644 index 0000000000..086500de38 --- /dev/null +++ b/activerecord/test/cases/time_precision_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/schema_dumping_helper" + +if subsecond_precision_supported? + class TimePrecisionTest < ActiveRecord::TestCase + include SchemaDumpingHelper + self.use_transactional_tests = false + + class Foo < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + Foo.reset_column_information + end + + teardown do + @connection.drop_table :foos, if_exists: true + end + + def test_time_data_type_with_precision + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :start, :time, precision: 3 + @connection.add_column :foos, :finish, :time, precision: 6 + assert_equal 3, Foo.columns_hash["start"].precision + assert_equal 6, Foo.columns_hash["finish"].precision + end + + def test_time_precision_is_truncated_on_assignment + @connection.create_table(:foos, force: true) + @connection.add_column :foos, :start, :time, precision: 0 + @connection.add_column :foos, :finish, :time, precision: 6 + + time = ::Time.now.change(nsec: 123456789) + foo = Foo.new(start: time, finish: time) + + assert_equal 0, foo.start.nsec + assert_equal 123456000, foo.finish.nsec + + foo.save! + foo.reload + + assert_equal 0, foo.start.nsec + assert_equal 123456000, foo.finish.nsec + end + + def test_passing_precision_to_time_does_not_set_limit + @connection.create_table(:foos, force: true) do |t| + t.time :start, precision: 3 + t.time :finish, precision: 6 + end + assert_nil Foo.columns_hash["start"].limit + assert_nil Foo.columns_hash["finish"].limit + end + + def test_invalid_time_precision_raises_error + assert_raises ActiveRecord::ActiveRecordError do + @connection.create_table(:foos, force: true) do |t| + t.time :start, precision: 7 + t.time :finish, precision: 7 + end + end + end + + def test_formatting_time_according_to_precision + @connection.create_table(:foos, force: true) do |t| + t.time :start, precision: 0 + t.time :finish, precision: 4 + end + time = ::Time.utc(2000, 1, 1, 12, 30, 0, 999999) + Foo.create!(start: time, finish: time) + assert foo = Foo.find_by(start: time) + assert_equal 1, Foo.where(finish: time).count + assert_equal time.to_s, foo.start.to_s + assert_equal time.to_s, foo.finish.to_s + assert_equal 000000, foo.start.usec + assert_equal 999900, foo.finish.usec + end + + def test_schema_dump_includes_time_precision + @connection.create_table(:foos, force: true) do |t| + t.time :start, precision: 4 + t.time :finish, precision: 6 + end + output = dump_table_schema("foos") + assert_match %r{t\.time\s+"start",\s+precision: 4$}, output + assert_match %r{t\.time\s+"finish",\s+precision: 6$}, output + end + + if current_adapter?(:PostgreSQLAdapter, :SQLServerAdapter) + def test_time_precision_with_zero_should_be_dumped + @connection.create_table(:foos, force: true) do |t| + t.time :start, precision: 0 + t.time :finish, precision: 0 + end + output = dump_table_schema("foos") + assert_match %r{t\.time\s+"start",\s+precision: 0$}, output + assert_match %r{t\.time\s+"finish",\s+precision: 0$}, output + end + end + end +end diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb new file mode 100644 index 0000000000..75ecd6fc40 --- /dev/null +++ b/activerecord/test/cases/timestamp_test.rb @@ -0,0 +1,488 @@ +# frozen_string_literal: true + +require "cases/helper" +require "support/ddl_helper" +require "models/developer" +require "models/computer" +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_updates_timestamp_with_given_time + previously_updated_at = @developer.updated_at + new_time = Time.utc(2015, 2, 16, 0, 0, 0) + @developer.touch(time: new_time) + + assert_not_equal previously_updated_at, @developer.updated_at + assert_equal new_time, @developer.updated_at + end + + def test_touching_an_attribute_updates_timestamp + previously_created_at = @developer.created_at + travel(1.second) do + @developer.touch(:created_at) + end + + assert_not @developer.created_at_changed?, "created_at should not be changed" + assert_not @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_update_at_attribute_as_symbol_updates_timestamp + travel(1.second) do + @developer.touch(:updated_at) + end + + assert_not @developer.updated_at_changed? + assert_not @developer.changed? + 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) + + now = Time.now.change(usec: 0) + + assert_not_equal previous_value, task.ending + assert_in_delta now, task.ending, 1 + end + + def test_touching_an_attribute_updates_timestamp_with_given_time + previously_updated_at = @developer.updated_at + previously_created_at = @developer.created_at + new_time = Time.utc(2015, 2, 16, 4, 54, 0) + @developer.touch(:created_at, time: new_time) + + assert_not_equal previously_created_at, @developer.created_at + assert_not_equal previously_updated_at, @developer.updated_at + assert_equal new_time, @developer.created_at + assert_equal new_time, @developer.updated_at + end + + def test_touching_many_attributes_updates_them + task = Task.first + previous_starting = task.starting + previous_ending = task.ending + task.touch(:starting, :ending) + + now = Time.now.change(usec: 0) + + assert_not_equal previous_starting, task.starting + assert_not_equal previous_ending, task.ending + assert_in_delta now, task.starting, 1 + assert_in_delta 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_predicate @developer, :no_touching? + assert_not_predicate @owner, :no_touching? + @developer.touch + end + + assert_not_predicate @developer, :no_touching? + assert_not_predicate @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_predicate @developer, :no_touching? + assert_predicate @owner, :no_touching? + @developer.touch + end + + assert_not_predicate @developer, :no_touching? + assert_not_predicate @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_predicate @developer, :no_touching? + + sleep(1) + end + end + + assert_not_predicate @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 + + travel(1.second) do + pet.name = "Fluffy the Third" + pet.save + end + + 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 + + travel(1.second) do + pet.destroy + end + + 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_predicate 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 + + travel(1.second) do + pet.name = "I'm a parrot" + pet.save + end + + 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 = created_at + end + end + + person = klass.create first_name: "David" + assert_not_equal person.born_at, nil + 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 + +class TimestampsWithoutTransactionTest < ActiveRecord::TestCase + include DdlHelper + self.use_transactional_tests = false + + class TimestampAttributePost < ActiveRecord::Base + attr_accessor :created_at, :updated_at + end + + def test_do_not_write_timestamps_on_save_if_they_are_not_attributes + with_example_table ActiveRecord::Base.connection, "timestamp_attribute_posts", "id integer primary key" do + post = TimestampAttributePost.new(id: 1) + post.save! # should not try to assign and persist created_at, updated_at + assert_nil post.created_at + assert_nil post.updated_at + end + end + + def test_index_is_created_for_both_timestamps + ActiveRecord::Base.connection.create_table(:foos, force: true) do |t| + t.timestamps null: true, index: true + end + + indexes = ActiveRecord::Base.connection.indexes("foos") + assert_equal ["created_at", "updated_at"], indexes.flat_map(&:columns).sort + ensure + ActiveRecord::Base.connection.drop_table(:foos) + end +end diff --git a/activerecord/test/cases/touch_later_test.rb b/activerecord/test/cases/touch_later_test.rb new file mode 100644 index 0000000000..cd3d5ed7d1 --- /dev/null +++ b/activerecord/test/cases/touch_later_test.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/invoice" +require "models/line_item" +require "models/topic" +require "models/node" +require "models/tree" + +class TouchLaterTest < ActiveRecord::TestCase + fixtures :nodes, :trees + + def test_touch_laster_raise_if_non_persisted + invoice = Invoice.new + Invoice.transaction do + assert_not_predicate invoice, :persisted? + assert_raises(ActiveRecord::ActiveRecordError) do + invoice.touch_later + end + end + end + + def test_touch_later_dont_set_dirty_attributes + invoice = Invoice.create! + invoice.touch_later + assert_not_predicate invoice, :changed? + end + + def test_touch_later_respects_no_touching_policy + time = Time.now.utc - 25.days + topic = Topic.create!(updated_at: time, created_at: time) + Topic.no_touching do + topic.touch_later + end + assert_equal time.to_i, topic.updated_at.to_i + end + + def test_touch_later_update_the_attributes + time = Time.now.utc - 25.days + topic = Topic.create!(updated_at: time, created_at: time) + assert_equal time.to_i, topic.updated_at.to_i + assert_equal time.to_i, topic.created_at.to_i + + Topic.transaction do + topic.touch_later(:created_at) + assert_not_equal time.to_i, topic.updated_at.to_i + assert_not_equal time.to_i, topic.created_at.to_i + + assert_equal time.to_i, topic.reload.updated_at.to_i + assert_equal time.to_i, topic.reload.created_at.to_i + end + assert_not_equal time.to_i, topic.reload.updated_at.to_i + assert_not_equal time.to_i, topic.reload.created_at.to_i + end + + def test_touch_touches_immediately + time = Time.now.utc - 25.days + topic = Topic.create!(updated_at: time, created_at: time) + assert_equal time.to_i, topic.updated_at.to_i + assert_equal time.to_i, topic.created_at.to_i + + Topic.transaction do + topic.touch_later(:created_at) + topic.touch + + assert_not_equal time, topic.reload.updated_at + assert_not_equal time, topic.reload.created_at + end + end + + def test_touch_later_an_association_dont_autosave_parent + time = Time.now.utc - 25.days + line_item = LineItem.create!(amount: 1) + invoice = Invoice.create!(line_items: [line_item]) + invoice.touch(time: time) + + Invoice.transaction do + line_item.update(amount: 2) + assert_equal time.to_i, invoice.reload.updated_at.to_i + end + + assert_not_equal time.to_i, invoice.updated_at.to_i + end + + def test_touch_touches_immediately_with_a_custom_time + time = (Time.now.utc - 25.days).change(nsec: 0) + topic = Topic.create!(updated_at: time, created_at: time) + assert_equal time, topic.updated_at + assert_equal time, topic.created_at + + Topic.transaction do + topic.touch_later(:created_at) + time = Time.now.utc - 2.days + topic.touch(time: time) + + assert_equal time.to_i, topic.reload.updated_at.to_i + assert_equal time.to_i, topic.reload.created_at.to_i + end + end + + def test_touch_later_dont_hit_the_db + invoice = Invoice.create! + assert_no_queries do + invoice.touch_later + end + end + + def test_touching_three_deep + previous_tree_updated_at = trees(:root).updated_at + previous_grandparent_updated_at = nodes(:grandparent).updated_at + previous_parent_updated_at = nodes(:parent_a).updated_at + previous_child_updated_at = nodes(:child_one_of_a).updated_at + + travel 5.seconds do + Node.create! parent: nodes(:child_one_of_a), tree: trees(:root) + end + + assert_not_equal nodes(:child_one_of_a).reload.updated_at, previous_child_updated_at + assert_not_equal nodes(:parent_a).reload.updated_at, previous_parent_updated_at + assert_not_equal nodes(:grandparent).reload.updated_at, previous_grandparent_updated_at + assert_not_equal trees(:root).reload.updated_at, previous_tree_updated_at + 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..aa6b7915a2 --- /dev/null +++ b/activerecord/test/cases/transaction_callbacks_test.rb @@ -0,0 +1,665 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/owner" +require "models/pet" +require "models/topic" + +class TransactionCallbacksTest < ActiveRecord::TestCase + 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 + 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" + + before_commit { |record| record.do_before_commit(nil) } + after_commit { |record| record.do_after_commit(nil) } + after_create_commit { |record| record.do_after_commit(:create) } + after_update_commit { |record| record.do_after_commit(:update) } + after_destroy_commit { |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 before_commit_block(on = nil, &block) + @before_commit ||= {} + @before_commit[on] ||= [] + @before_commit[on] << block + 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_before_commit(on) + blocks = @before_commit[on] if defined?(@before_commit) + blocks.each { |b| b.call(self) } if blocks + 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 + + # FIXME: Test behavior, not implementation. + def test_before_commit_exception_should_pop_transaction_stack + @first.before_commit_block { raise "better pop this txn from the stack!" } + + original_txn = @first.class.connection.current_transaction + + begin + @first.save! + fail + rescue + assert_equal original_txn, @first.class.connection.current_transaction + end + 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_destroy_after_transaction_commits_for_destroyed_new_record + new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) + add_transaction_execution_blocks new_record + + new_record.destroy + assert_equal [:commit_on_destroy], new_record.history + end + + def test_save_in_after_create_commit_wont_invoke_extra_after_create_commit + new_record = TopicWithCallbacks.new(title: "New topic", written_on: Date.today) + add_transaction_execution_blocks new_record + new_record.after_commit_block(:create) { |r| r.save! } + + new_record.save! + assert_equal [:commit_on_create, :commit_on_update], new_record.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_only_call_after_commit_on_top_level_transactions + @first.after_commit_block { |r| r.history << :after_commit } + assert_empty @first.history + + @first.transaction do + @first.transaction(requires_new: true) do + @first.touch + end + assert_empty @first.history + end + assert_equal [:after_commit], @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.after_commit_block { |r| r.history << :after_commit } + @first.after_rollback_block { |r| r.history << :after_rollback } + + assert_raises RuntimeError do + @first.transaction do + tx = @first.class.connection.transaction_manager.current_transaction + def tx.commit + raise + end + + @first.save + end + end + + assert_equal [:after_rollback], @first.history + 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_commit_callback_should_not_swallow_errors + @first.after_commit_block { fail "boom" } + assert_raises(RuntimeError) do + Topic.transaction do + @first.save! + end + end + end + + def test_after_commit_callback_when_raise_should_not_restore_state + first = TopicWithCallbacks.new + second = TopicWithCallbacks.new + first.after_commit_block { fail "boom" } + second.after_commit_block { fail "boom" } + + begin + Topic.transaction do + first.save! + assert_not_nil first.id + second.save! + assert_not_nil second.id + end + rescue + end + assert_not_nil first.id + assert_not_nil second.id + assert first.reload + end + + def test_after_rollback_callback_should_not_swallow_errors_when_set_to_raise + error_class = Class.new(StandardError) + @first.after_rollback_block { raise error_class } + assert_raises(error_class) do + Topic.transaction do + @first.save! + raise ActiveRecord::Rollback + end + end + end + + def test_after_rollback_callback_when_raise_should_restore_state + error_class = Class.new(StandardError) + + first = TopicWithCallbacks.new + second = TopicWithCallbacks.new + first.after_rollback_block { raise error_class } + second.after_rollback_block { raise error_class } + + begin + Topic.transaction do + first.save! + assert_not_nil first.id + second.save! + assert_not_nil second.id + raise ActiveRecord::Rollback + end + rescue error_class + end + assert_nil first.id + assert_nil second.id + end + + def test_after_rollback_callbacks_should_validate_on_condition + assert_raise(ArgumentError) { Topic.after_rollback(on: :save) } + e = assert_raise(ArgumentError) { Topic.after_rollback(on: "create") } + assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message) + end + + def test_after_commit_callbacks_should_validate_on_condition + assert_raise(ArgumentError) { Topic.after_commit(on: :save) } + e = assert_raise(ArgumentError) { Topic.after_commit(on: "create") } + assert_match(/:on conditions for after_commit and after_rollback callbacks have to be one of \[:create, :destroy, :update\]/, e.message) + end + + def test_after_commit_chain_not_called_on_errors + record_1 = TopicWithCallbacks.create! + record_2 = TopicWithCallbacks.create! + record_3 = TopicWithCallbacks.create! + callbacks = [] + record_1.after_commit_block { raise } + record_2.after_commit_block { callbacks << record_2.id } + record_3.after_commit_block { callbacks << record_3.id } + begin + TopicWithCallbacks.transaction do + record_1.save! + record_2.save! + record_3.save! + end + rescue + # From record_1.after_commit + end + assert_equal [], callbacks + 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 TransactionAfterCommitCallbacksWithOptimisticLockingTest < ActiveRecord::TestCase + class PersonWithCallbacks < ActiveRecord::Base + self.table_name = :people + + after_create_commit { |record| record.history << :commit_on_create } + after_update_commit { |record| record.history << :commit_on_update } + after_destroy_commit { |record| record.history << :commit_on_destroy } + + def history + @history ||= [] + end + end + + def test_after_commit_callbacks_with_optimistic_locking + person = PersonWithCallbacks.create!(first_name: "first name") + person.update!(first_name: "another name") + person.destroy + + assert_equal [:commit_on_create, :commit_on_update, :commit_on_destroy], person.history + end +end + +class CallbacksOnMultipleActionsTest < ActiveRecord::TestCase + self.use_transactional_tests = 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 } + + before_commit(if: :save_before_commit_history) { |record| record.history << :before_commit } + before_commit(if: :update_title) { |record| record.update(title: "before commit title") } + + def clear_history + @history = [] + end + + def history + @history ||= [] + end + + attr_accessor :save_before_commit_history, :update_title + 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 + + def test_before_commit_actions + topic = TopicWithCallbacksOnMultipleActions.new + topic.save_before_commit_history = true + topic.save + + assert_equal [:before_commit, :create_and_update, :create_and_destroy], topic.history + end + + def test_before_commit_update_in_same_transaction + topic = TopicWithCallbacksOnMultipleActions.new + topic.update_title = true + topic.save + + assert_equal "before commit title", topic.title + assert_equal "before commit title", topic.reload.title + end +end + +class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase + class TopicWithHistory < ActiveRecord::Base + self.table_name = :topics + + def self.clear_history + @@history = [] + end + + def self.history + @@history ||= [] + end + end + + class TopicWithCallbacksOnDestroy < TopicWithHistory + after_commit(on: :destroy) { |record| record.class.history << :destroy } + end + + class TopicWithCallbacksOnUpdate < TopicWithHistory + after_commit(on: :update) { |record| record.class.history << :update } + end + + def test_trigger_once_on_multiple_deletions + TopicWithCallbacksOnDestroy.clear_history + topic = TopicWithCallbacksOnDestroy.new + topic.save + topic_clone = TopicWithCallbacksOnDestroy.find(topic.id) + topic.destroy + topic_clone.destroy + + assert_equal [:destroy], TopicWithCallbacksOnDestroy.history + end + + def test_trigger_on_update_where_row_was_deleted + TopicWithCallbacksOnUpdate.clear_history + topic = TopicWithCallbacksOnUpdate.new + topic.save + topic_clone = TopicWithCallbacksOnUpdate.find(topic.id) + topic.destroy + topic_clone.author_name = "Test Author" + topic_clone.save + + assert_equal [], TopicWithCallbacksOnUpdate.history + end +end + +class TransactionEnrollmentCallbacksTest < ActiveRecord::TestCase + class TopicWithoutTransactionalEnrollmentCallbacks < ActiveRecord::Base + self.table_name = :topics + + before_commit_without_transaction_enrollment { |r| r.history << :before_commit } + after_commit_without_transaction_enrollment { |r| r.history << :after_commit } + after_rollback_without_transaction_enrollment { |r| r.history << :rollback } + + def history + @history ||= [] + end + end + + def setup + @topic = TopicWithoutTransactionalEnrollmentCallbacks.create! + end + + def test_commit_does_not_run_transactions_callbacks_without_enrollment + @topic.transaction do + @topic.content = "foo" + @topic.save! + end + assert_empty @topic.history + end + + def test_commit_run_transactions_callbacks_with_explicit_enrollment + @topic.transaction do + 2.times do + @topic.content = "foo" + @topic.save! + end + @topic.class.connection.add_transaction_record(@topic) + end + assert_equal [:before_commit, :after_commit], @topic.history + end + + def test_commit_run_transactions_callbacks_with_nested_transactions + @topic.transaction do + @topic.transaction(requires_new: true) do + @topic.content = "foo" + @topic.save! + @topic.class.connection.add_transaction_record(@topic) + end + end + assert_equal [:before_commit, :after_commit], @topic.history + end + + def test_rollback_does_not_run_transactions_callbacks_without_enrollment + @topic.transaction do + @topic.content = "foo" + @topic.save! + raise ActiveRecord::Rollback + end + assert_empty @topic.history + end + + def test_rollback_run_transactions_callbacks_with_explicit_enrollment + @topic.transaction do + 2.times do + @topic.content = "foo" + @topic.save! + end + @topic.class.connection.add_transaction_record(@topic) + raise ActiveRecord::Rollback + end + assert_equal [:rollback], @topic.history + end +end + +class CallbacksOnActionAndConditionTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class TopicWithCallbacksOnActionAndCondition < ActiveRecord::Base + self.table_name = :topics + + after_commit(on: [:create, :update], if: :run_callback?) { |record| record.history << :create_or_update } + + def clear_history + @history = [] + end + + def history + @history ||= [] + end + + def run_callback? + self.history << :run_callback? + true + end + + attr_accessor :save_before_commit_history, :update_title + end + + def test_callback_on_action_with_condition + topic = TopicWithCallbacksOnActionAndCondition.new + topic.save + assert_equal [:run_callback?, :create_or_update], topic.history + + topic.clear_history + topic.approved = true + topic.save + assert_equal [:run_callback?, :create_or_update], topic.history + + topic.clear_history + topic.destroy + assert_equal [], 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..2932969412 --- /dev/null +++ b/activerecord/test/cases/transaction_isolation_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "cases/helper" + +unless ActiveRecord::Base.connection.supports_transaction_isolation? + class TransactionIsolationUnsupportedTest < ActiveRecord::TestCase + self.use_transactional_tests = false + + class Tag < ActiveRecord::Base + end + + test "setting the isolation level raises an error" do + assert_raises(ActiveRecord::TransactionIsolationError) do + Tag.transaction(isolation: :serializable) { Tag.connection.materialize_transactions } + end + end + end +else + class TransactionIsolationTest < ActiveRecord::TestCase + self.use_transactional_tests = 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..45c93ca949 --- /dev/null +++ b/activerecord/test/cases/transactions_test.rb @@ -0,0 +1,1139 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" +require "models/developer" +require "models/computer" +require "models/book" +require "models/author" +require "models/post" +require "models/movie" + +class TransactionTest < ActiveRecord::TestCase + self.use_transactional_tests = false + fixtures :topics, :developers, :authors, :author_addresses, :posts + + def setup + @first, @second = Topic.find(1, 2).sort_by(&:id) + end + + def test_persisted_in_a_model_with_custom_primary_key_after_failed_save + movie = Movie.create + assert_not_predicate movie, :persisted? + end + + def test_raise_after_destroy + assert_not_predicate @first, :frozen? + + assert_raises(RuntimeError) { + Topic.transaction do + @first.destroy + assert_predicate @first, :frozen? + raise + end + } + + assert @first.reload + assert_not_predicate @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_not 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_add_to_null_transaction + topic = Topic.new + topic.add_to_transaction + 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_not 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_number_of_transactions_in_commit + num = nil + + Topic.connection.class_eval do + alias :real_commit_db_transaction :commit_db_transaction + define_method(:commit_db_transaction) do + num = transaction_manager.open_transactions + real_commit_db_transaction + end + end + + Topic.transaction do + @first.approved = true + @first.save! + end + + assert_equal 0, num + 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_not 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_not @second.approved?, "Second should still be changed in the objects" + + assert_not 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_not_predicate 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_not @first.approved + + Topic.transaction do + @first.approved = true + @first.save! + end + assert_not 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_transaction_state_is_cleared_when_record_is_persisted + author = Author.create! name: "foo" + author.name = nil + assert_not author.save + assert_not_predicate author, :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_not status + assert_equal posts_count, author.posts.reload.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.reload.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_not 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_not 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" + if id_snapshot.nil? + assert_nil new_topic.id, "The topic should have its old id" + else + assert_equal id_snapshot, new_topic.id, "The topic should have its old id" + end + 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_not new_topic.persisted?, "The topic should not be persisted" + assert_nil new_topic.id, "The topic should not have an ID" + end + + def test_callback_rollback_in_create_with_rollback_exception + topic = Class.new(Topic) { + def after_create_for_transaction + raise ActiveRecord::Rollback + end + } + + new_topic = topic.create(title: "A new topic") + assert_not 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_not Topic.find(2).approved?, "Second should have been unapproved" + end + + def test_nested_transaction_with_new_transaction_applies_parent_state_on_rollback + topic_one = Topic.new(title: "A new topic") + topic_two = Topic.new(title: "Another new topic") + + Topic.transaction do + topic_one.save + + Topic.transaction(requires_new: true) do + topic_two.save + + assert_predicate topic_one, :persisted? + assert_predicate topic_two, :persisted? + end + + raise ActiveRecord::Rollback + end + + assert_not_predicate topic_one, :persisted? + assert_not_predicate topic_two, :persisted? + end + + def test_nested_transaction_without_new_transaction_applies_parent_state_on_rollback + topic_one = Topic.new(title: "A new topic") + topic_two = Topic.new(title: "Another new topic") + + Topic.transaction do + topic_one.save + + Topic.transaction do + topic_two.save + + assert_predicate topic_one, :persisted? + assert_predicate topic_two, :persisted? + end + + raise ActiveRecord::Rollback + end + + assert_not_predicate topic_one, :persisted? + assert_not_predicate topic_two, :persisted? + end + + def test_double_nested_transaction_applies_parent_state_on_rollback + topic_one = Topic.new(title: "A new topic") + topic_two = Topic.new(title: "Another new topic") + topic_three = Topic.new(title: "Another new topic of course") + + Topic.transaction do + topic_one.save + + Topic.transaction do + topic_two.save + + Topic.transaction do + topic_three.save + end + end + + assert_predicate topic_one, :persisted? + assert_predicate topic_two, :persisted? + assert_predicate topic_three, :persisted? + + raise ActiveRecord::Rollback + end + + assert_not_predicate topic_one, :persisted? + assert_not_predicate topic_two, :persisted? + assert_not_predicate topic_three, :persisted? + 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_not @second.approved?, "Second should still be changed in the objects" + + assert_not 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_predicate @first.reload, :approved? + assert_not_predicate @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_predicate @first.reload, :approved? + assert_not_predicate @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_not_predicate @first.reload, :approved? + assert_not_predicate @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_predicate @first.reload, :approved? + + @first.approved = false + @first.save! + Topic.connection.release_savepoint("first") + assert_not_predicate @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 + assert_called(Topic.connection, :begin_db_transaction) do + Topic.connection.stub(:commit_db_transaction, -> { raise("OH NOES") }) do + assert_called(Topic.connection, :rollback_db_transaction) do + e = assert_raise RuntimeError do + Topic.transaction do + Topic.connection.materialize_transactions + end + end + assert_equal "OH NOES", e.message + end + end + end + end + + def test_rollback_when_saving_a_frozen_record + topic = Topic.new(title: "test") + topic.freeze + e = assert_raise(FrozenError) { topic.save } + # Not good enough, but we can't do much + # about it since there is no specific error + # for frozen objects. + assert_match(/frozen/i, e.message) + assert_not topic.persisted?, "not persisted" + assert_nil topic.id + assert topic.frozen?, "not frozen" + end + + def test_rollback_when_thread_killed + return if in_memory_db? + + queue = Queue.new + thread = Thread.new do + Topic.transaction do + @first.approved = true + @second.approved = false + @first.save + + queue.push nil + sleep + + @second.save + end + end + + queue.pop + thread.kill + thread.join + + assert @first.approved?, "First should still be changed in the objects" + assert_not @second.approved?, "Second should still be changed in the objects" + + assert_not Topic.find(1).approved?, "First shouldn't have been approved" + assert Topic.find(2).approved?, "Second should still be approved" + 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_not topic_1.persisted?, "not persisted" + assert_nil topic_1.id + assert_not topic_2.persisted?, "not persisted" + assert_nil topic_2.id + assert_not topic_3.persisted?, "not persisted" + assert_nil topic_3.id + assert @first.persisted?, "persisted" + assert_not_nil @first.id + assert_not @second.destroyed?, "not destroyed" + end + + def test_restore_frozen_state_after_double_destroy + topic = Topic.create + reply = topic.replies.create + + Topic.transaction do + topic.destroy # calls #destroy on reply (since dependent: destroy) + reply.destroy + + raise ActiveRecord::Rollback + end + + assert_not_predicate reply, :frozen? + assert_not_predicate topic, :frozen? + end + + def test_restore_new_record_after_double_save + topic = Topic.new + + Topic.transaction do + topic.save! + topic.save! + raise ActiveRecord::Rollback + end + + assert_nil topic.id + assert_predicate topic, :new_record? + end + + def test_dont_restore_new_record_in_subsequent_transaction + topic = Topic.new + + Topic.transaction do + topic.save! + topic.save! + end + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + assert_predicate topic, :persisted? + assert_not_predicate topic, :new_record? + end + + def test_restore_id_after_rollback + topic = Topic.new + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + assert_nil topic.id + end + + def test_restore_custom_primary_key_after_rollback + movie = Movie.new(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + assert_nil movie.movieid + end + + def test_assign_id_after_rollback + topic = Topic.create! + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + topic.id = nil + assert_nil topic.id + end + + def test_assign_custom_primary_key_after_rollback + movie = Movie.create!(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + movie.movieid = nil + assert_nil movie.movieid + end + + def test_read_attribute_after_rollback + topic = Topic.new + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + assert_nil topic.read_attribute(:id) + end + + def test_read_attribute_with_custom_primary_key_after_rollback + movie = Movie.new(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + assert_nil movie.read_attribute(:movieid) + end + + def test_write_attribute_after_rollback + topic = Topic.create! + + Topic.transaction do + topic.save! + raise ActiveRecord::Rollback + end + + topic.write_attribute(:id, nil) + assert_nil topic.id + end + + def test_write_attribute_with_custom_primary_key_after_rollback + movie = Movie.create!(name: "foo") + + Movie.transaction do + movie.save! + raise ActiveRecord::Rollback + end + + movie.write_attribute(:movieid, nil) + assert_nil movie.movieid + end + + def test_rollback_of_frozen_records + topic = Topic.create.freeze + Topic.transaction do + topic.destroy + raise ActiveRecord::Rollback + end + assert topic.frozen?, "frozen" + end + + def test_rollback_for_freshly_persisted_records + topic = Topic.create + Topic.transaction do + topic.destroy + raise ActiveRecord::Rollback + end + assert topic.persisted?, "persisted" + 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_includes Topic.column_names, "stuff" + + Topic.reset_column_information + Topic.connection.remove_column("topics", "stuff") + assert_not_includes Topic.column_names, "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_predicate transaction, :open? + assert_not_predicate transaction.state, :rolledback? + assert_not_predicate transaction.state, :committed? + + transaction.rollback + + assert_predicate transaction.state, :rolledback? + assert_not_predicate transaction.state, :committed? + end + + def test_transactions_state_from_commit + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + assert_predicate transaction, :open? + assert_not_predicate transaction.state, :rolledback? + assert_not_predicate transaction.state, :committed? + + transaction.commit + + assert_not_predicate transaction.state, :rolledback? + assert_predicate transaction.state, :committed? + end + + def test_set_state_method_is_deprecated + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.commit + + assert_deprecated do + transaction.state.set_state(:rolledback) + end + end + + def test_mark_transaction_state_as_committed + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.rollback + + assert_equal :committed, transaction.state.commit! + end + + def test_mark_transaction_state_as_rolledback + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.commit + + assert_equal :rolledback, transaction.state.rollback! + end + + def test_mark_transaction_state_as_nil + connection = Topic.connection + transaction = ActiveRecord::ConnectionAdapters::TransactionManager.new(connection).begin_transaction + + transaction.commit + + assert_nil transaction.state.nullify! + end + + def test_transaction_rollback_with_primarykeyless_tables + connection = ActiveRecord::Base.connection + connection.create_table(:transaction_without_primary_keys, force: true, id: false) do |t| + t.integer :thing_id + end + + klass = Class.new(ActiveRecord::Base) do + self.table_name = "transaction_without_primary_keys" + after_commit { } # necessary to trigger the has_transactional_callbacks branch + end + + assert_no_difference(-> { klass.count }) do + ActiveRecord::Base.transaction do + klass.create! + raise ActiveRecord::Rollback + end + end + ensure + connection.drop_table "transaction_without_primary_keys", if_exists: true + end + + def test_empty_transaction_is_not_materialized + assert_no_queries do + Topic.transaction { } + end + end + + def test_unprepared_statement_materializes_transaction + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { Topic.where("1=1").first } + end + end + + if ActiveRecord::Base.connection.prepared_statements + def test_prepared_statement_materializes_transaction + Topic.first + + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { Topic.first } + end + end + end + + def test_savepoint_does_not_materialize_transaction + assert_no_queries do + Topic.transaction do + Topic.transaction(requires_new: true) { } + end + end + end + + def test_raising_does_not_materialize_transaction + assert_raise(RuntimeError) do + assert_no_queries do + Topic.transaction { raise } + end + end + end + + def test_accessing_raw_connection_materializes_transaction + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { Topic.connection.raw_connection } + end + end + + def test_accessing_raw_connection_disables_lazy_transactions + Topic.connection.raw_connection + + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction { } + end + end + + def test_checking_in_connection_reenables_lazy_transactions + connection = Topic.connection_pool.checkout + connection.raw_connection + Topic.connection_pool.checkin connection + + assert_no_queries do + connection.transaction { } + end + end + + def test_transactions_can_be_manually_materialized + assert_sql(/BEGIN/i, /COMMIT/i) do + Topic.transaction do + Topic.connection.materialize_transactions + end + end + 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 + throw(:abort) + end + end + end +end + +class TransactionsWithTransactionalFixturesTest < ActiveRecord::TestCase + self.use_transactional_tests = 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_not_predicate @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_not_predicate @first.reload, :approved? + end +end if Topic.connection.supports_savepoints? + +if ActiveRecord::Base.connection.supports_transaction_isolation? + class ConcurrentTransactionTest < TransactionTest + # This will cause transactions to overlap and fail unless they are performed on + # separate database connections. + 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(&:join) + 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(&:join) + end + + assert_equal original_salary, Developer.find(1).salary + end + end +end diff --git a/activerecord/test/cases/type/adapter_specific_registry_test.rb b/activerecord/test/cases/type/adapter_specific_registry_test.rb new file mode 100644 index 0000000000..b58bdd5549 --- /dev/null +++ b/activerecord/test/cases/type/adapter_specific_registry_test.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class AdapterSpecificRegistryTest < ActiveRecord::TestCase + test "a class can be registered for a symbol" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, ::String) + registry.register(:bar, ::Array) + + assert_equal "", registry.lookup(:foo) + assert_equal [], registry.lookup(:bar) + end + + test "a block can be registered" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo) do |*args| + [*args, "block for foo"] + end + registry.register(:bar) do |*args| + [*args, "block for bar"] + end + + assert_equal [:foo, 1, "block for foo"], registry.lookup(:foo, 1) + assert_equal [:foo, 2, "block for foo"], registry.lookup(:foo, 2) + assert_equal [:bar, 1, 2, 3, "block for bar"], registry.lookup(:bar, 1, 2, 3) + end + + test "filtering by adapter" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String, adapter: :sqlite3) + registry.register(:foo, Array, adapter: :postgresql) + + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + assert_equal [], registry.lookup(:foo, adapter: :postgresql) + end + + test "an error is raised if both a generic and adapter specific type match" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String) + registry.register(:foo, Array, adapter: :postgresql) + + assert_raises TypeConflictError do + registry.lookup(:foo, adapter: :postgresql) + end + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + end + + test "a generic type can explicitly override an adapter specific type" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String, override: true) + registry.register(:foo, Array, adapter: :postgresql) + + assert_equal "", registry.lookup(:foo, adapter: :postgresql) + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + end + + test "a generic type can explicitly allow an adapter type to be used instead" do + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String, override: false) + registry.register(:foo, Array, adapter: :postgresql) + + assert_equal [], registry.lookup(:foo, adapter: :postgresql) + assert_equal "", registry.lookup(:foo, adapter: :sqlite3) + end + + test "a reasonable error is given when no type is found" do + registry = Type::AdapterSpecificRegistry.new + + e = assert_raises(ArgumentError) do + registry.lookup(:foo) + end + + assert_equal "Unknown type :foo", e.message + end + + test "construct args are passed to the type" do + type = Struct.new(:args) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, type) + + assert_equal type.new, registry.lookup(:foo) + assert_equal type.new(:ordered_arg), registry.lookup(:foo, :ordered_arg) + assert_equal type.new(keyword: :arg), registry.lookup(:foo, keyword: :arg) + assert_equal type.new(keyword: :arg), registry.lookup(:foo, keyword: :arg, adapter: :postgresql) + end + + test "registering a modifier" do + decoration = Struct.new(:value) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String) + registry.register(:bar, Hash) + registry.add_modifier({ array: true }, decoration) + + assert_equal decoration.new(""), registry.lookup(:foo, array: true) + assert_equal decoration.new({}), registry.lookup(:bar, array: true) + assert_equal "", registry.lookup(:foo) + end + + test "registering multiple modifiers" do + decoration = Struct.new(:value) + other_decoration = Struct.new(:value) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, String) + registry.add_modifier({ array: true }, decoration) + registry.add_modifier({ range: true }, other_decoration) + + assert_equal "", registry.lookup(:foo) + assert_equal decoration.new(""), registry.lookup(:foo, array: true) + assert_equal other_decoration.new(""), registry.lookup(:foo, range: true) + assert_equal( + decoration.new(other_decoration.new("")), + registry.lookup(:foo, array: true, range: true) + ) + end + + test "registering adapter specific modifiers" do + decoration = Struct.new(:value) + type = Struct.new(:args) + registry = Type::AdapterSpecificRegistry.new + registry.register(:foo, type) + registry.add_modifier({ array: true }, decoration, adapter: :postgresql) + + assert_equal( + decoration.new(type.new(keyword: :arg)), + registry.lookup(:foo, array: true, adapter: :postgresql, keyword: :arg) + ) + assert_equal( + type.new(array: true), + registry.lookup(:foo, array: true, adapter: :sqlite3) + ) + end + end +end diff --git a/activerecord/test/cases/type/date_time_test.rb b/activerecord/test/cases/type/date_time_test.rb new file mode 100644 index 0000000000..c9558e25b5 --- /dev/null +++ b/activerecord/test/cases/type/date_time_test.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/task" + +module ActiveRecord + module Type + class DateTimeTest < ActiveRecord::TestCase + def test_datetime_seconds_precision_applied_to_timestamp + skip "This test is invalid if subsecond precision isn't supported" unless subsecond_precision_supported? + p = Task.create!(starting: ::Time.now) + assert_equal p.starting.usec, p.reload.starting.usec + end + end + end +end diff --git a/activerecord/test/cases/type/integer_test.rb b/activerecord/test/cases/type/integer_test.rb new file mode 100644 index 0000000000..15d1a675a1 --- /dev/null +++ b/activerecord/test/cases/type/integer_test.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/company" + +module ActiveRecord + module Type + class IntegerTest < ActiveRecord::TestCase + test "casting ActiveRecord models" do + type = Type::Integer.new + firm = Firm.create(name: "Apple") + assert_nil type.cast(firm) + end + + test "values which are out of range can be re-assigned" do + klass = Class.new(ActiveRecord::Base) do + self.table_name = "posts" + attribute :foo, :integer + end + model = klass.new + + model.foo = 2147483648 + model.foo = 1 + + assert_equal 1, model.foo + 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..9e7810a6a5 --- /dev/null +++ b/activerecord/test/cases/type/string_test.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + class StringTypeTest < ActiveRecord::TestCase + test "string mutations are detected" do + klass = Class.new(Base) + klass.table_name = "authors" + + author = klass.create!(name: "Sean") + assert_not_predicate author, :changed? + + author.name << " Griffin" + assert_predicate author, :name_changed? + + author.save! + author.reload + + assert_equal "Sean Griffin", author.name + assert_not_predicate 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..1ce515a90c --- /dev/null +++ b/activerecord/test/cases/type/type_map_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +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 = +"" + mapping = TypeMap.new + + mapping.register_type(/varchar/i, string) + + assert_equal mapping.lookup("varchar(20)"), string + end + + def test_aliasing_types + string = +"" + 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 = +"" + 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 + + def test_fetch + mapping = TypeMap.new + mapping.register_type(1, "string") + + assert_equal "string", mapping.fetch(1) { "int" } + assert_equal "int", mapping.fetch(2) { "int" } + end + + def test_fetch_yields_args + mapping = TypeMap.new + + assert_equal "foo-1-2-3", mapping.fetch("foo", 1, 2, 3) { |*args| args.join("-") } + assert_equal "bar-1-2-3", mapping.fetch("bar", 1, 2, 3) { |*args| args.join("-") } + end + + def test_fetch_memoizes + mapping = TypeMap.new + + looked_up = false + mapping.register_type(1) do + fail if looked_up + looked_up = true + "string" + end + + assert_equal "string", mapping.fetch(1) + assert_equal "string", mapping.fetch(1) + end + + def test_fetch_memoizes_on_args + mapping = TypeMap.new + mapping.register_type("foo") { |*args| args.join("-") } + + assert_equal "foo-1-2-3", mapping.fetch("foo", 1, 2, 3) { |*args| args.join("-") } + assert_equal "foo-2-3-4", mapping.fetch("foo", 2, 3, 4) { |*args| args.join("-") } + end + + def test_register_clears_cache + mapping = TypeMap.new + + mapping.register_type(1, "string") + mapping.lookup(1) + mapping.register_type(1, "int") + + assert_equal "int", mapping.lookup(1) + end + end + end +end diff --git a/activerecord/test/cases/type/unsigned_integer_test.rb b/activerecord/test/cases/type/unsigned_integer_test.rb new file mode 100644 index 0000000000..dd05cf3fff --- /dev/null +++ b/activerecord/test/cases/type/unsigned_integer_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module Type + class UnsignedIntegerTest < ActiveRecord::TestCase + test "unsigned int max value is in range" do + assert_equal(4294967295, UnsignedInteger.new.serialize(4294967295)) + end + + test "minus value is out of range" do + assert_raises(ActiveModel::RangeError) do + UnsignedInteger.new.serialize(-1) + end + end + end + end +end diff --git a/activerecord/test/cases/type_test.rb b/activerecord/test/cases/type_test.rb new file mode 100644 index 0000000000..93ae563c8b --- /dev/null +++ b/activerecord/test/cases/type_test.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "cases/helper" + +class TypeTest < ActiveRecord::TestCase + setup do + @old_registry = ActiveRecord::Type.registry + ActiveRecord::Type.registry = ActiveRecord::Type::AdapterSpecificRegistry.new + end + + teardown do + ActiveRecord::Type.registry = @old_registry + end + + test "registering a new type" do + type = Struct.new(:args) + ActiveRecord::Type.register(:foo, type) + + assert_equal type.new(:arg), ActiveRecord::Type.lookup(:foo, :arg) + end + + test "looking up a type for a specific adapter" do + type = Struct.new(:args) + pgtype = Struct.new(:args) + ActiveRecord::Type.register(:foo, type, override: false) + ActiveRecord::Type.register(:foo, pgtype, adapter: :postgresql) + + assert_equal type.new, ActiveRecord::Type.lookup(:foo, adapter: :sqlite) + assert_equal pgtype.new, ActiveRecord::Type.lookup(:foo, adapter: :postgresql) + end + + test "lookup defaults to the current adapter" do + current_adapter = ActiveRecord::Base.connection.adapter_name.downcase.to_sym + type = Struct.new(:args) + adapter_type = Struct.new(:args) + ActiveRecord::Type.register(:foo, type, override: false) + ActiveRecord::Type.register(:foo, adapter_type, adapter: current_adapter) + + assert_equal adapter_type.new, ActiveRecord::Type.lookup(:foo) + end +end diff --git a/activerecord/test/cases/types_test.rb b/activerecord/test/cases/types_test.rb new file mode 100644 index 0000000000..3f7fb0a604 --- /dev/null +++ b/activerecord/test/cases/types_test.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "cases/helper" + +module ActiveRecord + module ConnectionAdapters + class TypesTest < ActiveRecord::TestCase + def test_attributes_which_are_invalid_for_database_can_still_be_reassigned + type_which_cannot_go_to_the_database = Type::Value.new + def type_which_cannot_go_to_the_database.serialize(*) + raise + end + klass = Class.new(ActiveRecord::Base) do + self.table_name = "posts" + attribute :foo, type_which_cannot_go_to_the_database + end + model = klass.new + + model.foo = "foo" + model.foo = "bar" + + assert_equal "bar", model.foo + 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..9eefc32745 --- /dev/null +++ b/activerecord/test/cases/unconnected_test.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "cases/helper" + +class TestRecord < ActiveRecord::Base +end + +class TestUnconnectedAdapter < ActiveRecord::TestCase + self.use_transactional_tests = 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_not @underlying.active?, "Removed adapter should no longer be active" + end +end diff --git a/activerecord/test/cases/unsafe_raw_sql_test.rb b/activerecord/test/cases/unsafe_raw_sql_test.rb new file mode 100644 index 0000000000..d5d8f2a09a --- /dev/null +++ b/activerecord/test/cases/unsafe_raw_sql_test.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/post" +require "models/comment" + +class UnsafeRawSqlTest < ActiveRecord::TestCase + fixtures :posts, :comments + + test "order: allows string column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows symbol column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:title).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:title).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows downcase symbol direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :asc).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :asc).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows upcase symbol direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("ASC")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: :ASC).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: :ASC).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows string direction" do + ids_expected = Post.order(Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(title: "asc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(title: "asc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows multiple columns" do + ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, :title).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, :title).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows mixed" do + ids_expected = Post.order(Arel.sql("author_id"), Arel.sql("title") => Arel.sql("asc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(:author_id, title: :asc).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(:author_id, title: :asc).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows table and column name" do + ids_expected = Post.order(Arel.sql("title")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows column name and direction in string" do + ids_expected = Post.order(Arel.sql("title desc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("title desc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("title desc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows table name, column name and direction in string" do + ids_expected = Post.order(Arel.sql("title desc")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("posts.title desc").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("posts.title desc").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: allows NULLS FIRST and NULLS LAST too" do + raise "precondition failed" if Post.count < 2 + + # Ensure there are NULL and non-NULL post types. + Post.first.update_column(:type, nil) + Post.last.update_column(:type, "Programming") + + ["asc", "desc", ""].each do |direction| + %w(first last).each do |position| + ids_expected = Post.order(Arel.sql("type #{direction} nulls #{position}")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order("type #{direction} nulls #{position}").pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order("type #{direction} nulls #{position}").pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + end + end if current_adapter?(:PostgreSQLAdapter) + + test "order: disallows invalid column name" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order("len(title) asc").pluck(:id) + end + end + end + + test "order: disallows invalid direction" do + with_unsafe_raw_sql_disabled do + assert_raises(ArgumentError) do + Post.order(title: :foo).pluck(:id) + end + end + end + + test "order: disallows invalid column with direction" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order("len(title)" => :asc).pluck(:id) + end + end + end + + test "order: always allows Arel" do + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(Arel.sql("length(title)")).pluck(:title) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(Arel.sql("length(title)")).pluck(:title) } + + assert_equal ids_depr, ids_disabled + end + + test "order: allows Arel.sql with binds" do + ids_expected = Post.order(Arel.sql("REPLACE(title, 'misc', 'zzzz'), id")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order([Arel.sql("REPLACE(title, ?, ?), id"), "misc", "zzzz"]).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: disallows invalid bind statement" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order(["REPLACE(title, ?, ?), id", "misc", "zzzz"]).pluck(:id) + end + end + end + + test "order: disallows invalid Array arguments" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.order(["author_id", "length(title)"]).pluck(:id) + end + end + end + + test "order: allows valid Array arguments" do + ids_expected = Post.order(Arel.sql("author_id, length(title)")).pluck(:id) + + ids_depr = with_unsafe_raw_sql_deprecated { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) } + ids_disabled = with_unsafe_raw_sql_disabled { Post.order(["author_id", Arel.sql("length(title)")]).pluck(:id) } + + assert_equal ids_expected, ids_depr + assert_equal ids_expected, ids_disabled + end + + test "order: logs deprecation warning for unrecognized column" do + with_unsafe_raw_sql_deprecated do + assert_deprecated(/Dangerous query method/) do + Post.order("length(title)") + end + end + end + + test "pluck: allows string column name" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("title") } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("title") } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: allows symbol column name" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title) } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title) } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: allows multiple column names" do + values_expected = Post.pluck(Arel.sql("title"), Arel.sql("id")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:title, :id) } + values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:title, :id) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows column names with includes" do + values_expected = Post.includes(:comments).pluck(Arel.sql("title"), Arel.sql("id")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, :id) } + values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, :id) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows auto-generated attributes" do + values_expected = Post.pluck(Arel.sql("tags_count")) + + values_depr = with_unsafe_raw_sql_deprecated { Post.pluck(:tags_count) } + values_disabled = with_unsafe_raw_sql_disabled { Post.pluck(:tags_count) } + + assert_equal values_expected, values_depr + assert_equal values_expected, values_disabled + end + + test "pluck: allows table and column names" do + titles_expected = Post.pluck(Arel.sql("title")) + + titles_depr = with_unsafe_raw_sql_deprecated { Post.pluck("posts.title") } + titles_disabled = with_unsafe_raw_sql_disabled { Post.pluck("posts.title") } + + assert_equal titles_expected, titles_depr + assert_equal titles_expected, titles_disabled + end + + test "pluck: disallows invalid column name" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.pluck("length(title)") + end + end + end + + test "pluck: disallows invalid column name amongst valid names" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.pluck(:title, "length(title)") + end + end + end + + test "pluck: disallows invalid column names with includes" do + with_unsafe_raw_sql_disabled do + assert_raises(ActiveRecord::UnknownAttributeReference) do + Post.includes(:comments).pluck(:title, "length(title)") + end + end + end + + test "pluck: always allows Arel" do + values_depr = with_unsafe_raw_sql_deprecated { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) } + values_disabled = with_unsafe_raw_sql_disabled { Post.includes(:comments).pluck(:title, Arel.sql("length(title)")) } + + assert_equal values_depr, values_disabled + end + + test "pluck: logs deprecation warning" do + with_unsafe_raw_sql_deprecated do + assert_deprecated(/Dangerous query method/) do + Post.includes(:comments).pluck(:title, "length(title)") + end + end + end + + def with_unsafe_raw_sql_disabled(&blk) + with_config(:disabled, &blk) + end + + def with_unsafe_raw_sql_deprecated(&blk) + with_config(:deprecated, &blk) + end + + def with_config(new_value, &blk) + old_value = ActiveRecord::Base.allow_unsafe_raw_sql + ActiveRecord::Base.allow_unsafe_raw_sql = new_value + blk.call + ensure + ActiveRecord::Base.allow_unsafe_raw_sql = old_value + end +end diff --git a/activerecord/test/cases/validations/absence_validation_test.rb b/activerecord/test/cases/validations/absence_validation_test.rb new file mode 100644 index 0000000000..1982734f02 --- /dev/null +++ b/activerecord/test/cases/validations/absence_validation_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/face" +require "models/interest" +require "models/man" +require "models/topic" + +class AbsenceValidationTest < ActiveRecord::TestCase + def test_non_association + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :name + end + + assert_predicate boy_klass.new, :valid? + assert_not_predicate boy_klass.new(name: "Alex"), :valid? + end + + def test_has_one_marked_for_destruction + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :face + end + + boy = boy_klass.new(face: Face.new) + assert_not boy.valid?, "should not be valid if has_one association is present" + assert_equal 1, boy.errors[:face].size, "should only add one error" + + boy.face.mark_for_destruction + assert boy.valid?, "should be valid if association is marked for destruction" + end + + def test_has_many_marked_for_destruction + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :interests + end + boy = boy_klass.new + boy.interests << [i1 = Interest.new, i2 = Interest.new] + assert_not boy.valid?, "should not be valid if has_many association is present" + + i1.mark_for_destruction + assert_not boy.valid?, "should not be valid if has_many association is present" + + i2.mark_for_destruction + assert_predicate boy, :valid? + end + + def test_does_not_call_to_a_on_associations + boy_klass = Class.new(Man) do + def self.name; "Boy" end + validates_absence_of :face + end + + face_with_to_a = Face.new + def face_with_to_a.to_a; ["(/)", '(\)']; end + + assert_nothing_raised { boy_klass.new(face: face_with_to_a).valid? } + end + + def test_validates_absence_of_virtual_attribute_on_model + repair_validations(Interest) do + Interest.attr_accessor(:token) + Interest.validates_absence_of(:token) + + interest = Interest.create!(topic: "Thought Leadering") + assert_predicate interest, :valid? + + interest.token = "tl" + + assert_predicate interest, :invalid? + end + 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..ce6d42b34b --- /dev/null +++ b/activerecord/test/cases/validations/association_validation_test.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +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_not_predicate t, :valid? + assert_predicate 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_predicate 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_not_predicate r, :valid? + assert_predicate r.errors[:topic], :any? + r.topic.content = "non-empty" + assert_predicate 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_predicate t, :invalid? + t.replies.first.mark_for_destruction + assert_predicate t, :valid? + end + + def test_validates_associated_without_marked_for_destruction + reply = Class.new do + def valid? + true + end + end + Topic.validates_associated(:replies) + t = Topic.new + t.define_singleton_method(:replies) { [reply.new] } + assert_predicate 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_not_operator 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_not_predicate r, :valid? + assert_predicate r.errors[:topic], :any? + + r.topic = Topic.first + assert_predicate 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..703c24b340 --- /dev/null +++ b/activerecord/test/cases/validations/i18n_generate_message_validation_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +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..b7c52ea18c --- /dev/null +++ b/activerecord/test/cases/validations/i18n_validation_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" + +class I18nValidationTest < ActiveRecord::TestCase + repair_validations(Topic, Reply) + + def setup + repair_validations(Topic, Reply) + 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" }], + [ "given on condition", { on: [:create, :update] }, {}] + ] + + 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 + assert_called_with(@topic.errors, :generate_message, [:title, :taken, generate_message_options.merge(value: "unique!")]) do + @topic.valid? + end + end + end + + COMMON_CASES.each do |name, validation_options, generate_message_options| + test "validates_associated on generated message #{name}" do + Topic.validates_associated :replies, validation_options + assert_called_with(replied_topic.errors, :generate_message, [:replies, :invalid, generate_message_options.merge(value: replied_topic.replies)]) do + replied_topic.save + end + end + end + + 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..a7cb718043 --- /dev/null +++ b/activerecord/test/cases/validations/length_validation_test.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/owner" +require "models/pet" +require "models/person" + +class LengthValidationTest < ActiveRecord::TestCase + fixtures :owners + + setup do + @owner = Class.new(Owner) do + def self.name; "Owner"; end + end + end + + def test_validates_size_of_association + assert_nothing_raised { @owner.validates_size_of :pets, minimum: 1 } + o = @owner.new("name" => "nopets") + assert_not o.save + assert_predicate o.errors[:pets], :any? + o.pets.build("name" => "apet") + assert_predicate o, :valid? + end + + def test_validates_size_of_association_using_within + assert_nothing_raised { @owner.validates_size_of :pets, within: 1..2 } + o = @owner.new("name" => "nopets") + assert_not o.save + assert_predicate o.errors[:pets], :any? + + o.pets.build("name" => "apet") + assert_predicate o, :valid? + + 2.times { o.pets.build("name" => "apet") } + assert_not o.save + assert_predicate o.errors[:pets], :any? + end + + def test_validates_size_of_association_utf8 + @owner.validates_size_of :pets, minimum: 1 + o = @owner.new("name" => "あいうえおかきくけこ") + assert_not o.save + assert_predicate o.errors[:pets], :any? + o.pets.build("name" => "あいうえおかきくけこ") + assert_predicate o, :valid? + end + + def test_validates_size_of_respects_records_marked_for_destruction + @owner.validates_size_of :pets, minimum: 1 + owner = @owner.new + assert_not owner.save + assert_predicate owner.errors[:pets], :any? + pet = owner.pets.build + assert_predicate owner, :valid? + assert owner.save + + pet_count = Pet.count + assert_not owner.update pets_attributes: [ { _destroy: 1, id: pet.id } ] + assert_not_predicate owner, :valid? + assert_predicate owner.errors[:pets], :any? + assert_equal pet_count, Pet.count + end + + def test_validates_length_of_virtual_attribute_on_model + repair_validations(Pet) do + Pet.attr_accessor(:nickname) + Pet.validates_length_of(:name, minimum: 1) + Pet.validates_length_of(:nickname, minimum: 1) + + pet = Pet.create!(name: "Fancy Pants", nickname: "Fancy") + + assert_predicate pet, :valid? + + pet.nickname = "" + + assert_predicate pet, :invalid? + 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..4b9cbe9098 --- /dev/null +++ b/activerecord/test/cases/validations/presence_validation_test.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +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_predicate b, :invalid? + + b.name = "Alex" + assert_predicate 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_predicate b, :valid? + + f.mark_for_destruction + assert_predicate 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_predicate b, :valid? + + i1.mark_for_destruction + assert_predicate b, :valid? + + i2.mark_for_destruction + assert_predicate 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 + + def test_validates_presence_of_virtual_attribute_on_model + repair_validations(Interest) do + Interest.attr_accessor(:abbreviation) + Interest.validates_presence_of(:topic) + Interest.validates_presence_of(:abbreviation) + + interest = Interest.create!(topic: "Thought Leadering", abbreviation: "tl") + assert_predicate interest, :valid? + + interest.abbreviation = "" + + assert_predicate interest, :invalid? + end + end + + def test_validations_run_on_persisted_record + repair_validations(Interest) do + interest = Interest.new + interest.save! + assert_predicate interest, :valid? + + Interest.validates_presence_of(:topic) + + assert_not_predicate interest, :valid? + end + end + + def test_validates_presence_with_on_context + repair_validations(Interest) do + Interest.validates_presence_of(:topic, on: :required_name) + interest = Interest.new + interest.save! + assert_not interest.valid?(:required_name) + end + 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..8f6f47e5fb --- /dev/null +++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb @@ -0,0 +1,557 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" +require "models/warehouse_thing" +require "models/guid" +require "models/event" +require "models/dashboard" +require "models/uuid_item" +require "models/author" +require "models/person" +require "models/essay" + +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 TopicWithUniqEvent < Topic + belongs_to :event, foreign_key: :parent_id + validates :event, uniqueness: true +end + +class BigIntTest < ActiveRecord::Base + INT_MAX_VALUE = 2147483647 + self.table_name = "cars" + validates :engines_count, uniqueness: true, inclusion: { in: 0..INT_MAX_VALUE } +end + +class BigIntReverseTest < ActiveRecord::Base + INT_MAX_VALUE = 2147483647 + self.table_name = "cars" + validates :engines_count, inclusion: { in: 0..INT_MAX_VALUE } + validates :engines_count, uniqueness: true +end + +class CoolTopic < Topic + validates_uniqueness_of :id +end + +class TopicWithAfterCreate < Topic + after_create :set_author + + def set_author + update!(author_name: "#{title} #{id}") + end +end + +class UniquenessValidationTest < ActiveRecord::TestCase + INT_MAX_VALUE = 2147483647 + + fixtures :topics, "warehouse-things" + + 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_not t2.valid?, "Shouldn't be valid" + assert_not 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_predicate 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_not t2.valid?, "Shouldn't be valid" + assert_not 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_not_predicate t2, :valid? + assert t2.errors[:title] + end + + def test_validate_uniqueness_when_integer_out_of_range + entry = BigIntTest.create(engines_count: INT_MAX_VALUE + 1) + assert_equal entry.errors[:engines_count], ["is not included in the list"] + end + + def test_validate_uniqueness_when_integer_out_of_range_show_order_does_not_matter + entry = BigIntReverseTest.create(engines_count: INT_MAX_VALUE + 1) + assert_equal entry.errors[:engines_count], ["is not included in the list"] + 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_not 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_scope_invalid_syntax + error = assert_raises(ArgumentError) do + Reply.validates_uniqueness_of(:content, scope: { parent_id: false }) + end + assert_match(/Pass a symbol or an array of symbols instead/, error.to_s) + 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_not r2.valid?, "Saving r2 first time" + end + + def test_validate_uniqueness_with_polymorphic_object_scope + Essay.validates_uniqueness_of(:name, scope: :writer) + + a = Author.create(name: "Sergey") + p = Person.create(first_name: "Sergey") + + e1 = a.essays.create(name: "Essay") + assert e1.valid?, "Saving e1" + + e2 = p.essays.create(name: "Essay") + assert e2.valid?, "Saving e2" + 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_not 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_not 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_not 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_not 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_not r3.valid?, "Saving r3" + + r3.author_name = "jj" + assert r3.save, "Saving r3 the second time." + + r3.author_name = "jeremy" + assert_not 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_not t2.valid?, "Shouldn't be valid" + assert_not t2.save, "Shouldn't save t2 as unique" + assert_predicate t2.errors[:title], :any? + assert_predicate t2.errors[:parent_id], :any? + assert_equal ["has already been taken"], t2.errors[:title] + + t2.title = "I'm truly UNIQUE!" + assert_not t2.valid?, "Shouldn't be valid" + assert_not t2.save, "Shouldn't save t2 as unique" + assert_empty t2.errors[:title] + assert_predicate 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_not t2_utf8.valid?, "Shouldn't be valid" + assert_not 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_empty t2.errors[:title] + assert_empty t2.errors[:parent_id] + 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_empty t3.errors[:title] + assert_empty t3.errors[:parent_id] + 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_not_predicate t2, :valid? + assert t2.errors[:title] + end + + def test_validate_uniqueness_with_non_standard_table_names + i1 = WarehouseThing.create(value: 1000) + assert_not 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_not_predicate 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 + if current_adapter?(:SQLite3Adapter) + # Event.title has limit 5, but SQLite doesn't truncate. + e1 = Event.create(title: "abcdefgh") + assert e1.valid?, "Could not create an event with a unique 8 characters title" + + e2 = Event.create(title: "abcdefgh") + assert_not e2.valid?, "Created an event whose title is not unique" + elsif current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter) + assert_raise(ActiveRecord::ValueTooLong) do + Event.create(title: "abcdefgh") + end + else + assert_raise(ActiveRecord::StatementInvalid) do + Event.create(title: "abcdefgh") + end + end + end + + def test_validate_uniqueness_with_limit_and_utf8 + if current_adapter?(:SQLite3Adapter) + # Event.title has limit 5, but SQLite doesn't truncate. + e1 = Event.create(title: "一二三四五六七八") + assert e1.valid?, "Could not create an event with a unique 8 characters title" + + e2 = Event.create(title: "一二三四五六七八") + assert_not e2.valid?, "Created an event whose title is not unique" + elsif current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :OracleAdapter, :SQLServerAdapter) + assert_raise(ActiveRecord::ValueTooLong) do + Event.create(title: "一二三四五六七八") + end + else + assert_raise(ActiveRecord::StatementInvalid) do + Event.create(title: "一二三四五六七八") + end + end + 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_not 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_not 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_not 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_not 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_not 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 + + def test_validate_uniqueness_on_existing_relation + event = Event.create + assert_predicate TopicWithUniqEvent.create(event: event), :valid? + + topic = TopicWithUniqEvent.new(event: event) + assert_not_predicate topic, :valid? + assert_equal ["has already been taken"], topic.errors[:event] + end + + def test_validate_uniqueness_on_empty_relation + topic = TopicWithUniqEvent.new + assert_predicate topic, :valid? + end + + def test_validate_uniqueness_of_custom_primary_key + klass = Class.new(ActiveRecord::Base) do + self.table_name = "keyboards" + self.primary_key = :key_number + + validates_uniqueness_of :key_number + + def self.name + "Keyboard" + end + end + + klass.create!(key_number: 10) + key2 = klass.create!(key_number: 11) + + key2.key_number = 10 + assert_not_predicate key2, :valid? + end + + def test_validate_uniqueness_without_primary_key + klass = Class.new(ActiveRecord::Base) do + self.table_name = "dashboards" + + validates_uniqueness_of :dashboard_id + + def self.name; "Dashboard" end + end + + abc = klass.create!(dashboard_id: "abc") + assert_predicate klass.new(dashboard_id: "xyz"), :valid? + assert_not_predicate klass.new(dashboard_id: "abc"), :valid? + + abc.dashboard_id = "def" + + e = assert_raises ActiveRecord::UnknownPrimaryKey do + abc.save! + end + assert_match(/\AUnknown primary key for table dashboards in model/, e.message) + assert_match(/Can not validate uniqueness for persisted record without primary key.\z/, e.message) + end + + def test_validate_uniqueness_ignores_itself_when_primary_key_changed + Topic.validates_uniqueness_of(:title) + + t = Topic.new("title" => "This is a unique title") + assert t.save, "Should save t as unique" + + t.id += 1 + assert t.valid?, "Should be valid" + assert t.save, "Should still save t as unique" + end + + def test_validate_uniqueness_with_after_create_performing_save + TopicWithAfterCreate.validates_uniqueness_of(:title) + topic = TopicWithAfterCreate.create!(title: "Title1") + assert topic.author_name.start_with?("Title1") + + topic2 = TopicWithAfterCreate.new(title: "Title1") + assert_not_predicate topic2, :valid? + assert_equal(["has already been taken"], topic2.errors[:title]) + end + + def test_validate_uniqueness_uuid + skip unless current_adapter?(:PostgreSQLAdapter) + item = UuidItem.create!(uuid: SecureRandom.uuid, title: "item1") + item.update(title: "item1-title2") + assert_empty item.errors + + item2 = UuidValidatingItem.create!(uuid: SecureRandom.uuid, title: "item2") + item2.update(title: "item2-title2") + assert_empty item2.errors + end + + def test_validate_uniqueness_regular_id + item = CoolTopic.create!(title: "MyItem") + assert_empty item.errors + + item2 = CoolTopic.new(id: item.id, title: "MyItem2") + assert_not_predicate item2, :valid? + + assert_equal(["has already been taken"], item2.errors[:id]) + 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..6dc3b64b2b --- /dev/null +++ b/activerecord/test/cases/validations_repair_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActiveRecord + module ValidationsRepairHelper + extend ActiveSupport::Concern + + module ClassMethods + def repair_validations(*model_classes) + teardown do + model_classes.each(&:clear_validators!) + end + end + end + + def repair_validations(*model_classes) + yield if block_given? + ensure + model_classes.each(&:clear_validators!) + 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..9a70934b7e --- /dev/null +++ b/activerecord/test/cases/validations_test.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" +require "models/developer" +require "models/computer" +require "models/parrot" +require "models/company" +require "models/price_estimate" + +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_predicate 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_not 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_invalid_using_multiple_contexts + r = WrongReply.new(title: "Wrong Create") + assert r.invalid?([:special_case, :create]) + assert_equal "Invalid", r.errors[:author_name].join + assert_equal "is Wrong Create", r.errors[:title].join + 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_not 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 do + IncorporealModel.validates_acceptance_of(:incorporeal_column) + end + end + + def test_throw_away_typing + d = Developer.new("name" => "David", "salary" => "100,000") + assert_not_predicate d, :valid? + assert_equal 100, d.salary + assert_equal "100,000", d.salary_before_type_cast + end + + def test_validates_acceptance_of_with_undefined_attribute_methods + Topic.validates_acceptance_of(:approved) + topic = Topic.new(approved: true) + Topic.undefine_attribute_methods + assert topic.approved + 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 + + def test_numericality_validation_with_mutation + klass = Class.new(Topic) do + attribute :wibble, :string + validates_numericality_of :wibble, only_integer: true + end + + topic = klass.new(wibble: "123-4567") + topic.wibble.gsub!("-", "") + + assert_predicate topic, :valid? + end + + def test_numericality_validation_checks_against_raw_value + klass = Class.new(Topic) do + def self.model_name + ActiveModel::Name.new(self, nil, "Topic") + end + attribute :wibble, :decimal, scale: 2, precision: 9 + validates_numericality_of :wibble, greater_than_or_equal_to: BigDecimal("97.18") + end + + assert_not_predicate klass.new(wibble: "97.179"), :valid? + assert_not_predicate klass.new(wibble: 97.179), :valid? + assert_not_predicate klass.new(wibble: BigDecimal("97.179")), :valid? + end + + def test_numericality_validator_wont_be_affected_by_custom_getter + price_estimate = PriceEstimate.new(price: 50) + + assert_equal "$50.00", price_estimate.price + assert_equal 50, price_estimate.price_before_type_cast + assert_equal 50, price_estimate.read_attribute(:price) + + assert_predicate price_estimate, :price_came_from_user? + assert_predicate price_estimate, :valid? + + price_estimate.save! + + assert_not_predicate price_estimate, :price_came_from_user? + assert_predicate price_estimate, :valid? + end + + def test_acceptance_validator_doesnt_require_db_connection + klass = Class.new(ActiveRecord::Base) do + self.table_name = "posts" + end + klass.reset_column_information + + assert_no_queries do + klass.validates_acceptance_of(:foo) + end + end +end diff --git a/activerecord/test/cases/view_test.rb b/activerecord/test/cases/view_test.rb new file mode 100644 index 0000000000..36b9df7ba5 --- /dev/null +++ b/activerecord/test/cases/view_test.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/book" +require "support/schema_dumping_helper" + +module ViewBehavior + include SchemaDumpingHelper + extend ActiveSupport::Concern + + included do + fixtures :books + end + + class Ebook < ActiveRecord::Base + self.table_name = "ebooks'" + self.primary_key = "id" + end + + def setup + super + @connection = ActiveRecord::Base.connection + create_view "ebooks'", <<~SQL + SELECT id, name, status FROM books WHERE format = 'ebook' + SQL + end + + def teardown + super + drop_view "ebooks'" + end + + def test_reading + books = Ebook.all + assert_equal [books(:rfr).id], books.map(&:id) + assert_equal ["Ruby for Rails"], books.map(&:name) + end + + def test_views + assert_equal [Ebook.table_name], @connection.views + end + + def test_view_exists + view_name = Ebook.table_name + assert @connection.view_exists?(view_name), "'#{view_name}' view should exist" + end + + def test_table_exists + view_name = Ebook.table_name + assert_not @connection.table_exists?(view_name), "'#{view_name}' table should not exist" + end + + def test_views_ara_valid_data_sources + view_name = Ebook.table_name + assert @connection.data_source_exists?(view_name), "'#{view_name}' should be a data source" + end + + def test_column_definitions + assert_equal([["id", :integer], + ["name", :string], + ["status", :integer]], Ebook.columns.map { |c| [c.name, c.type] }) + end + + def test_attributes + assert_equal({ "id" => 2, "name" => "Ruby for Rails", "status" => 0 }, + Ebook.first.attributes) + end + + def test_does_not_assume_id_column_as_primary_key + model = Class.new(ActiveRecord::Base) do + self.table_name = "ebooks'" + end + assert_nil model.primary_key + end + + def test_does_not_dump_view_as_table + schema = dump_table_schema "ebooks'" + assert_no_match %r{create_table "ebooks'"}, schema + end + + private + def quote_table_name(name) + @connection.quote_table_name(name) + end +end + +if ActiveRecord::Base.connection.supports_views? + class ViewWithPrimaryKeyTest < ActiveRecord::TestCase + include ViewBehavior + + private + def create_view(name, query) + @connection.execute "CREATE VIEW #{quote_table_name(name)} AS #{query}" + end + + def drop_view(name) + @connection.execute "DROP VIEW #{quote_table_name(name)}" if @connection.view_exists? name + end + end + + class ViewWithoutPrimaryKeyTest < ActiveRecord::TestCase + include SchemaDumpingHelper + fixtures :books + + class Paperback < ActiveRecord::Base; end + + setup do + @connection = ActiveRecord::Base.connection + @connection.execute <<~SQL + CREATE VIEW paperbacks + AS SELECT name, status FROM books WHERE format = 'paperback' + SQL + end + + teardown do + @connection.execute "DROP VIEW paperbacks" if @connection.view_exists? "paperbacks" + end + + def test_reading + books = Paperback.all + assert_equal ["Agile Web Development with Rails"], books.map(&:name) + end + + def test_views + assert_equal [Paperback.table_name], @connection.views + end + + def test_view_exists + view_name = Paperback.table_name + assert @connection.view_exists?(view_name), "'#{view_name}' view should exist" + end + + def test_table_exists + view_name = Paperback.table_name + assert_not @connection.table_exists?(view_name), "'#{view_name}' table should not exist" + end + + def test_column_definitions + assert_equal([["name", :string], + ["status", :integer]], Paperback.columns.map { |c| [c.name, c.type] }) + end + + def test_attributes + assert_equal({ "name" => "Agile Web Development with Rails", "status" => 2 }, + Paperback.first.attributes) + end + + def test_does_not_have_a_primary_key + assert_nil Paperback.primary_key + end + + def test_does_not_dump_view_as_table + schema = dump_table_schema "paperbacks" + assert_no_match %r{create_table "paperbacks"}, schema + end + end + + # sqlite dose not support CREATE, INSERT, and DELETE for VIEW + if current_adapter?(:Mysql2Adapter, :SQLServerAdapter, :PostgreSQLAdapter) + + class UpdateableViewTest < ActiveRecord::TestCase + self.use_transactional_tests = false + fixtures :books + + class PrintedBook < ActiveRecord::Base + self.primary_key = "id" + end + + setup do + @connection = ActiveRecord::Base.connection + @connection.execute <<~SQL + CREATE VIEW printed_books + AS SELECT id, name, status, format FROM books WHERE format = 'paperback' + SQL + end + + teardown do + @connection.execute "DROP VIEW printed_books" if @connection.view_exists? "printed_books" + end + + def test_update_record + book = PrintedBook.first + book.name = "AWDwR" + book.save! + book.reload + assert_equal "AWDwR", book.name + end + + def test_insert_record + PrintedBook.create! name: "Rails in Action", status: 0, format: "paperback" + + new_book = PrintedBook.last + assert_equal "Rails in Action", new_book.name + end + + def test_update_record_to_fail_view_conditions + book = PrintedBook.first + book.format = "ebook" + book.save! + + assert_raises ActiveRecord::RecordNotFound do + book.reload + end + end + end + end # end of `if current_adapter?(:Mysql2Adapter, :PostgreSQLAdapter, :SQLServerAdapter)` +end # end of `if ActiveRecord::Base.connection.supports_views?` + +if ActiveRecord::Base.connection.supports_materialized_views? + class MaterializedViewTest < ActiveRecord::PostgreSQLTestCase + include ViewBehavior + + private + def create_view(name, query) + @connection.execute "CREATE MATERIALIZED VIEW #{quote_table_name(name)} AS #{query}" + end + + def drop_view(name) + @connection.execute "DROP MATERIALIZED VIEW #{quote_table_name(name)}" if @connection.view_exists? name + end + 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..60ebdce178 --- /dev/null +++ b/activerecord/test/cases/yaml_serialization_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/topic" +require "models/reply" +require "models/post" +require "models/author" + +class YamlSerializationTest < ActiveRecord::TestCase + fixtures :topics, :authors, :author_addresses, :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 + + def test_a_yaml_version_is_provided_for_future_backwards_compat + coder = {} + Topic.first.encode_with(coder) + + assert coder["active_record_yaml_version"] + end + + def test_deserializing_rails_41_yaml + topic = YAML.load(yaml_fixture("rails_4_1")) + + assert_predicate topic, :new_record? + assert_nil topic.id + assert_equal "The First Topic", topic.title + assert_equal({ omg: :lol }, topic.content) + end + + def test_deserializing_rails_4_2_0_yaml + topic = YAML.load(yaml_fixture("rails_4_2_0")) + + assert_not_predicate topic, :new_record? + assert_equal 1, topic.id + assert_equal "The First Topic", topic.title + assert_equal("Have a nice day", topic.content) + end + + def test_yaml_encoding_keeps_mutations + author = Author.first + author.name = "Sean" + dumped = YAML.load(YAML.dump(author)) + + assert_equal "Sean", dumped.name + assert_equal author.name_was, dumped.name_was + assert_equal author.changes, dumped.changes + end + + def test_yaml_encoding_keeps_false_values + topic = Topic.first + topic.approved = false + dumped = YAML.load(YAML.dump(topic)) + + assert_equal false, dumped.approved + end + + private + + def yaml_fixture(file_name) + path = File.expand_path( + "../support/yaml_compatibility_fixtures/#{file_name}.yml", + __dir__ + ) + File.read(path) + end +end |