Skip to content

Commit 3bb8cb9

Browse files
authored
Merge pull request rails#46522 from fatkodima/belongs_to-avoid-queries
Avoid validating `belongs_to` association if it has not changed
2 parents eed5655 + e5d1514 commit 3bb8cb9

File tree

9 files changed

+110
-1
lines changed

9 files changed

+110
-1
lines changed

activerecord/CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,21 @@
1+
* Avoid validating `belongs_to` association if it has not changed.
2+
3+
Previously, when updating a record, Active Record will perform an extra query to check for the presence of
4+
`belongs_to` associations (if the presence is configured to be mandatory), even if that attribute hasn't changed.
5+
6+
Currently, only `belongs_to`-related columns are checked for presence. It is possible to have orphaned records with
7+
this approach. To avoid this problem, you need to use a foreign key.
8+
9+
This behavior can be controlled by configuration:
10+
11+
```ruby
12+
config.active_record.belongs_to_required_validates_foreign_key = false
13+
```
14+
15+
and will be disabled by default with `load_defaults 7.1`.
16+
17+
*fatkodima*
18+
119
* `has_one` and `belongs_to` associations now define a `reset_association` method
220
on the owner model (where `association` is the name of the association). This
321
method unloads the cached associate record, if any, and causes the next access

activerecord/lib/active_record.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,9 @@ def self.global_executor_concurrency # :nodoc:
271271
singleton_class.attr_accessor :raise_on_assign_to_attr_readonly
272272
self.raise_on_assign_to_attr_readonly = false
273273

274+
singleton_class.attr_accessor :belongs_to_required_validates_foreign_key
275+
self.belongs_to_required_validates_foreign_key = true
276+
274277
##
275278
# :singleton-method:
276279
# Specify a threshold for the size of query result sets. If the number of

activerecord/lib/active_record/associations/builder/belongs_to.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,20 @@ def self.define_validations(model, reflection)
123123
super
124124

125125
if required
126-
model.validates_presence_of reflection.name, message: :required
126+
if ActiveRecord.belongs_to_required_validates_foreign_key
127+
model.validates_presence_of reflection.name, message: :required
128+
else
129+
condition = lambda { |record|
130+
foreign_key = reflection.foreign_key
131+
foreign_type = reflection.foreign_type
132+
133+
record.read_attribute(foreign_key).nil? ||
134+
record.attribute_changed?(foreign_key) ||
135+
(reflection.polymorphic? && (record.read_attribute(foreign_type).nil? || record.attribute_changed?(foreign_type)))
136+
}
137+
138+
model.validates_presence_of reflection.name, message: :required, if: condition
139+
end
127140
end
128141
end
129142

activerecord/lib/active_record/railtie.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ class Railtie < Rails::Railtie # :nodoc:
3737
config.active_record.query_log_tags_format = :legacy
3838
config.active_record.cache_query_log_tags = false
3939
config.active_record.raise_on_assign_to_attr_readonly = false
40+
config.active_record.belongs_to_required_validates_foreign_key = true
4041

4142
config.active_record.queues = ActiveSupport::InheritableOptions.new
4243

activerecord/test/cases/associations/belongs_to_associations_test.rb

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,6 +1665,61 @@ def test_multiple_counter_cache_with_after_create_update
16651665
assert_not comment.author_changed?
16661666
assert comment.author_previously_changed?
16671667
end
1668+
1669+
class ShipRequired < ActiveRecord::Base
1670+
self.table_name = "ships"
1671+
belongs_to :developer, required: true
1672+
end
1673+
1674+
test "runs parent presence check if parent changed or nil" do
1675+
david = developers(:david)
1676+
jamis = developers(:jamis)
1677+
1678+
ship = ShipRequired.create!(name: "Medusa", developer: david)
1679+
assert_equal david, ship.developer
1680+
1681+
assert_queries(2) do # UPDATE and SELECT to check developer presence
1682+
ship.update!(developer_id: jamis.id)
1683+
end
1684+
1685+
ship.update_column(:developer_id, nil)
1686+
ship.reload
1687+
1688+
assert_queries(2) do # UPDATE and SELECT to check developer presence
1689+
ship.update!(developer_id: david.id)
1690+
end
1691+
end
1692+
1693+
test "skips parent presence check if parent has not changed" do
1694+
david = developers(:david)
1695+
ship = ShipRequired.create!(name: "Medusa", developer: david)
1696+
ship.reload # unload developer association
1697+
1698+
assert_queries(1) do # UPDATE only, no SELECT to check developer presence
1699+
ship.update!(name: "Leviathan")
1700+
end
1701+
end
1702+
1703+
test "runs parent presence check if parent has not changed and belongs_to_required_validates_foreign_key is set" do
1704+
original_value = ActiveRecord.belongs_to_required_validates_foreign_key
1705+
ActiveRecord.belongs_to_required_validates_foreign_key = true
1706+
1707+
model = Class.new(ActiveRecord::Base) do
1708+
self.table_name = "ships"
1709+
def self.name; "Temp"; end
1710+
belongs_to :developer, required: true
1711+
end
1712+
1713+
david = developers(:david)
1714+
ship = model.create!(name: "Medusa", developer: david)
1715+
ship.reload # unload developer association
1716+
1717+
assert_queries(2) do # UPDATE and SELECT to check developer presence
1718+
ship.update!(name: "Leviathan")
1719+
end
1720+
ensure
1721+
ActiveRecord.belongs_to_required_validates_foreign_key = original_value
1722+
end
16681723
end
16691724

16701725
class BelongsToWithForeignKeyTest < ActiveRecord::TestCase

activerecord/test/cases/helper.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
QUOTED_TYPE = ActiveRecord::Base.connection.quote_column_name("type")
3232

3333
ActiveRecord.raise_on_assign_to_attr_readonly = true
34+
ActiveRecord.belongs_to_required_validates_foreign_key = false
3435

3536
def current_adapter?(*types)
3637
types.any? do |type|

guides/source/configuring.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ Below are the default values associated with each target version. In cases of co
6666
- [`config.active_record.allow_deprecated_singular_associations_name`](#config-active-record-allow-deprecated-singular-associations-name): `false`
6767
- [`config.active_record.query_log_tags_format`](#config-active-record-query-log-tags-format): `:sqlcommenter`
6868
- [`config.active_record.raise_on_assign_to_attr_readonly`](#config-active-record-raise-on-assign-to-attr-readonly): `true`
69+
- [`config.active_record.belongs_to_required_validates_foreign_key`](#config-active-record-belongs-to-required-validates-foreign-key): `false`
6970
- [`config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction`](#config-active-record-run-commit-callbacks-on-first-saved-instances-in-transaction): `false`
7071
- [`config.active_record.sqlite3_adapter_strict_strings_by_default`](#config-active-record-sqlite3-adapter-strict-strings-by-default): `true`
7172
- [`config.active_support.default_message_encryptor_serializer`](#config-active-support-default-message-encryptor-serializer): `:json`
@@ -1002,6 +1003,17 @@ The default value depends on the `config.load_defaults` target version:
10021003
| (original) | `nil` |
10031004
| 5.0 | `true` |
10041005

1006+
#### `config.active_record.belongs_to_required_validates_foreign_key`
1007+
1008+
Enable validating only parent-related columns for presence when the parent is mandatory.
1009+
The previous behavior was to validate the presence of the parent record, which performed an extra query
1010+
to get the parent every time the child record was updated, even when parent has not changed.
1011+
1012+
| Starting with version | The default value is |
1013+
| --------------------- | -------------------- |
1014+
| (original) | `true` |
1015+
| 7.1 | `false` |
1016+
10051017
#### `config.active_record.action_on_strict_loading_violation`
10061018

10071019
Enables raising or logging an exception if strict_loading is set on an

railties/lib/rails/application/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,7 @@ def load_defaults(target_version)
289289
active_record.sqlite3_adapter_strict_strings_by_default = true
290290
active_record.query_log_tags_format = :sqlcommenter
291291
active_record.raise_on_assign_to_attr_readonly = true
292+
active_record.belongs_to_required_validates_foreign_key = false
292293
end
293294

294295
if respond_to?(:action_dispatch)

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,8 @@
112112
# behavior would allow assignment but silently not persist changes to the
113113
# database.
114114
# Rails.application.config.active_record.raise_on_assign_to_attr_readonly = true
115+
116+
# Enable validating only parent-related columns for presence when the parent is mandatory.
117+
# The previous behavior was to validate the presence of the parent record, which performed an extra query
118+
# to get the parent every time the child record was updated, even when parent has not changed.
119+
# Rails.application.config.active_record.belongs_to_required_validates_foreign_key = false

0 commit comments

Comments
 (0)