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 @@ def has_many(name, scope = nil, **options, &extension)
#
# 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 @@ def has_many(name, scope = nil, **options, &extension)
# 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 @@ def self.valid_options(options)
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 Associations
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.