Merge pull request #42079 from cheshire137/has-one-disable-join

Add disable_joins option to has_one relation
This commit is contained in:
Eileen M. Uchitelle 2021-05-07 09:21:16 -04:00 committed by GitHub
commit 226f22e843
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 109 additions and 7 deletions

View File

@ -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.

View File

@ -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 <tt>:inverse_of</tt> 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 <tt>:through</tt> model will be automatically created and removed
# as appropriate. Otherwise, the collection is read-only, so you should manipulate the
# <tt>:through</tt> 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 <tt>:inverse_of</tt> 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 <tt>:through</tt> 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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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.