diff options
Diffstat (limited to 'activerecord/test/cases/associations')
20 files changed, 13076 insertions, 0 deletions
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 |