diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 0c3f7b9e3f..36562b3dc0 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,27 @@ +* Add option to disable joins for `has_one` associations. + + In a multiple database application, associations can't join across + databases. When set, this option instructs Rails to generate 2 or + more queries rather than generating joins for `has_one` associations. + + Set the option on a has one through association: + + ```ruby + class Person + belongs_to :dog + has_one :veterinarian, through: :dog, disable_joins: true + end + ``` + + Then instead of generating join SQL, two queries are used for `@person.veterinarian`: + + ``` + SELECT "dogs"."id" FROM "dogs" WHERE "dogs"."person_id" = ? [["person_id", 1]] + SELECT "veterinarians".* FROM "veterinarians" WHERE "veterinarians"."dog_id" = ? [["dog_id", 1]] + ``` + + *Sarah Vessels*, *Eileen M. Uchitelle* + * `Arel::Visitors::Dot` now renders a complete set of properties when visiting `Arel::Nodes::SelectCore`, `SelectStatement`, `InsertStatement`, `UpdateStatement`, and `DeleteStatement`, which fixes #42026. Previously, some properties were omitted. diff --git a/activerecord/lib/active_record/associations.rb b/activerecord/lib/active_record/associations.rb index 48fe3004ea..45551336f2 100644 --- a/activerecord/lib/active_record/associations.rb +++ b/activerecord/lib/active_record/associations.rb @@ -1555,6 +1555,22 @@ module ActiveRecord # # If you are going to modify the association (rather than just read from it), then it is # a good idea to set the :inverse_of option. + # [:disable_joins] + # Specifies whether joins should be skipped for an association. If set to true, two or more queries + # will be generated. Note that in some cases, if order or limit is applied, it will be done in-memory + # due to database limitations. This option is only applicable on `has_one :through` associations as + # `has_one` alone does not perform a join. + # + # If the association on the join model is a #belongs_to, the collection can be modified + # and the records on the :through model will be automatically created and removed + # as appropriate. Otherwise, the collection is read-only, so you should manipulate the + # :through association directly. + # + # If you are going to modify the association (rather than just read from it), then it is + # a good idea to set the :inverse_of option on the source association on the + # join model. This allows associated records to be built which will automatically create + # the appropriate join model records when they are saved. (See the 'Association Join Models' + # section above.) # [:source] # Specifies the source association name used by #has_one :through queries. # Only use it if the name cannot be inferred from the association. @@ -1596,6 +1612,7 @@ module ActiveRecord # has_one :attachment, as: :attachable # has_one :boss, -> { readonly } # has_one :club, through: :membership + # has_one :club, through: :membership, disable_joins: true # has_one :primary_address, -> { where(primary: true) }, through: :addressables, source: :addressable # has_one :credit_card, required: true # has_one :credit_card, strict_loading: true diff --git a/activerecord/lib/active_record/associations/builder/has_one.rb b/activerecord/lib/active_record/associations/builder/has_one.rb index 1773faa01b..c6ca0cada7 100644 --- a/activerecord/lib/active_record/associations/builder/has_one.rb +++ b/activerecord/lib/active_record/associations/builder/has_one.rb @@ -11,6 +11,7 @@ module ActiveRecord::Associations::Builder # :nodoc: valid += [:as, :foreign_type] if options[:as] valid += [:ensuring_owner_was] if options[:dependent] == :destroy_async valid += [:through, :source, :source_type] if options[:through] + valid += [:disable_joins] if options[:disable_joins] && options[:through] valid end diff --git a/activerecord/lib/active_record/associations/has_one_through_association.rb b/activerecord/lib/active_record/associations/has_one_through_association.rb index 10978b2d93..2d9371475c 100644 --- a/activerecord/lib/active_record/associations/has_one_through_association.rb +++ b/activerecord/lib/active_record/associations/has_one_through_association.rb @@ -6,6 +6,12 @@ module ActiveRecord class HasOneThroughAssociation < HasOneAssociation #:nodoc: include ThroughAssociation + def find_target + return scope.first if disable_joins + + super + end + private def replace(record, save = true) create_through_record(record, save) diff --git a/activerecord/test/cases/associations/has_one_through_disable_joins_associations_test.rb b/activerecord/test/cases/associations/has_one_through_disable_joins_associations_test.rb new file mode 100644 index 0000000000..bf9d87b4ac --- /dev/null +++ b/activerecord/test/cases/associations/has_one_through_disable_joins_associations_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "cases/helper" +require "models/member" +require "models/organization" + +class HasOneThroughDisableJoinsAssociationsTest < ActiveRecord::TestCase + fixtures :members, :organizations + + def setup + @member = members(:groucho) + @organization = organizations(:discordians) + @member.organization = @organization + @member.save! + @member.reload + end + + def test_counting_on_disable_joins_through + no_joins = capture_sql { @member.organization_without_joins } + joins = capture_sql { @member.organization } + + assert_equal @member.organization, @member.organization_without_joins + assert_equal 2, no_joins.count + assert_equal 1, joins.count + assert_match(/INNER JOIN/, joins.first) + no_joins.each do |nj| + assert_no_match(/INNER JOIN/, nj) + end + end + + def test_nil_on_disable_joins_through + member = members(:blarpy_winkup) + assert_nil assert_queries(1) { member.organization } + assert_nil assert_queries(1) { member.organization_without_joins } + end + + def test_preload_on_disable_joins_through + members = Member.preload(:organization, :organization_without_joins).to_a + assert_no_queries { members[0].organization } + assert_no_queries { members[0].organization_without_joins } + end +end diff --git a/activerecord/test/models/member.rb b/activerecord/test/models/member.rb index 9699a91f5c..40e88e7621 100644 --- a/activerecord/test/models/member.rb +++ b/activerecord/test/models/member.rb @@ -12,6 +12,7 @@ class Member < ActiveRecord::Base has_one :sponsor_club, through: :sponsor has_one :member_detail, inverse_of: false has_one :organization, through: :member_detail + has_one :organization_without_joins, through: :member_detail, disable_joins: true, source: :organization belongs_to :member_type has_many :nested_member_types, through: :member_detail, source: :member_type diff --git a/guides/source/active_record_multiple_databases.md b/guides/source/active_record_multiple_databases.md index 752d3928e1..df8bf9ab1a 100644 --- a/guides/source/active_record_multiple_databases.md +++ b/guides/source/active_record_multiple_databases.md @@ -462,8 +462,8 @@ connections globally. ### Handling associations with joins across databases As of Rails 7.0+, Active Record has an option for handling associations that would perform -a join across multiple databases. If you have a has many through association that you want to -disable joining and perform 2 or more queries, pass the `disable_joins: true` option. +a join across multiple databases. If you have a has many through or a has one through association +that you want to disable joining and perform 2 or more queries, pass the `disable_joins: true` option. For example: @@ -471,12 +471,16 @@ For example: class Dog < AnimalsRecord has_many :treats, through: :humans, disable_joins: true has_many :humans + + belongs_to :home + has_one :yard, through: :home, disable_joins: true end ``` -Previously calling `@dog.treats` without `disable_joins` would raise an error because databases are unable -to handle joins across clusters. With the `disable_joins` option, Rails will generate multiple select queries -to avoid attempting joining across clusters. For the above association `@dog.treats` would generate the +Previously calling `@dog.treats` without `disable_joins` or `@dog.yard` without `disable_joins` +would raise an error because databases are unable to handle joins across clusters. With the +`disable_joins` option, Rails will generate multiple select queries +to avoid attempting joining across clusters. For the above association, `@dog.treats` would generate the following SQL: ```sql @@ -484,14 +488,21 @@ SELECT "humans"."id" FROM "humans" WHERE "humans"."dog_id" = ? [["dog_id", 1]] SELECT "treats".* FROM "treats" WHERE "treats"."human_id" IN (?, ?, ?) [["human_id", 1], ["human_id", 2], ["human_id", 3]] ``` +While `@dog.yard` would generate the following SQL: + +```sql +SELECT "home"."id" FROM "homes" WHERE "homes"."dog_id" = ? [["dog_id", 1]] +SELECT "yards".* FROM "yards" WHERE "yards"."home_id" = ? [["home_id", 1]] +``` + There are some important things to be aware of with this option: 1) There may be performance implications since now two or more queries will be performed (depending on the association) rather than a join. If the select for `humans` returned a high number of IDs the select for `treats` may send too many IDs. -2) Since we are no longer performing joins a query with an order or limit is now sorted in-memory since +2) Since we are no longer performing joins, a query with an order or limit is now sorted in-memory since order from one table cannot be applied to another table. -3) This setting must be added to all associations that you want joining to be disabled. +3) This setting must be added to all associations where you want joining to be disabled. Rails can't guess this for you because association loading is lazy, to load `treats` in `@dog.treats` Rails already needs to know what SQL should be generated.