Skip to content

Commit 4b80303

Browse files
Edouard-chinbyroot
authored andcommitted
Prevent persisting invalid record:
- Fix rails#54267 - In rails#53951, the goal was to allow saving a record and its association even if the association had existing invalid records in the DB. We would not validate the association in that case. The problem of not validating, is that it stops the validation chain and it's possible that other records in subsequent relations are being modified and are invalid. e.g. `Company -has_many-> Developers -has_many-> Computers`, if a computer is changed with invalid value and the company get saved, this would bypass the computer validation and persist it. This commit fixes that by ensuring there are no validation errors from *changed* records in the whole association chain.
1 parent c00096f commit 4b80303

File tree

4 files changed

+101
-24
lines changed

4 files changed

+101
-24
lines changed

activerecord/lib/active_record/autosave_association.rb

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -370,27 +370,29 @@ def validate_collection_association(reflection)
370370
# enabled records if they're marked_for_destruction? or destroyed.
371371
def association_valid?(association, record)
372372
return true if record.destroyed? || (association.options[:autosave] && record.marked_for_destruction?)
373-
if custom_validation_context?
374-
context = validation_context
373+
374+
context = validation_context if custom_validation_context?
375+
return true if record.valid?(context)
376+
377+
if record.changed? || record.new_record? || context
378+
associated_errors = record.errors.objects
375379
else
376-
# If the associated record is unchanged we shouldn't auto validate it.
377-
# Even if a record is invalid you should still be able to create new references
378-
# to it.
379-
return true if !record.new_record? && !record.changed?
380+
# If there are existing invalid records in the DB, we should still be able to reference them.
381+
# Unless a record (no matter where in the association chain) is invalid and is being changed.
382+
associated_errors = record.errors.objects.select { |error| error.is_a?(Associations::NestedError) }
380383
end
381384

382-
unless valid = record.valid?(context)
383-
if association.options[:autosave]
384-
record.errors.each { |error|
385-
self.errors.objects.append(
386-
Associations::NestedError.new(association, error)
387-
)
388-
}
389-
else
390-
errors.add(association.reflection.name)
391-
end
385+
if association.options[:autosave]
386+
associated_errors.each { |error|
387+
errors.objects.append(
388+
Associations::NestedError.new(association, error)
389+
)
390+
}
391+
elsif associated_errors.any?
392+
errors.add(association.reflection.name)
392393
end
393-
valid
394+
395+
errors.any?
394396
end
395397

396398
# Is used as an around_save callback to check while saving a collection

activerecord/test/cases/autosave_association_test.rb

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
require "models/parrot"
2020
require "models/pirate"
2121
require "models/project"
22+
require "models/price_estimate"
2223
require "models/ship"
2324
require "models/ship_part"
2425
require "models/squeak"
@@ -1490,7 +1491,7 @@ def test_should_skip_validation_on_habtm_if_destroyed
14901491
assert_predicate @pirate, :valid?
14911492
end
14921493

1493-
def test_should_skip_validation_on_habtm_if_persisted_and_unchanged
1494+
def test_should_be_valid_on_habtm_if_persisted_and_unchanged
14941495
parrot = @pirate.parrots.create!(name: "parrots_1")
14951496
parrot.update_column(:name, "")
14961497
parrot.reload
@@ -1501,6 +1502,73 @@ def test_should_skip_validation_on_habtm_if_persisted_and_unchanged
15011502
new_pirate.save!
15021503
end
15031504

1505+
def test_should_be_invalid_on_habtm_when_any_record_in_the_association_chain_is_invalid_and_was_changed
1506+
treasure = @pirate.treasures.create!(name: "gold")
1507+
estimate = treasure.price_estimates.create!(price: 1)
1508+
estimate.update_columns(price: "not a number")
1509+
1510+
assert_not_predicate estimate, :valid?
1511+
1512+
treasures = @pirate.treasures.eager_load(:price_estimates).to_a
1513+
treasures.first.price_estimates.first.price = "not a price"
1514+
new_pirate = Pirate.new(
1515+
catchphrase: "Arr",
1516+
treasures: treasures,
1517+
)
1518+
1519+
assert_raises(ActiveRecord::RecordInvalid) do
1520+
new_pirate.save!
1521+
end
1522+
assert_equal(["Treasures is invalid"], new_pirate.errors.full_messages)
1523+
end
1524+
1525+
def test_should_be_invalid_on_habtm_when_any_record_in_the_association_chain_is_invalid_and_was_changed_with_autosave
1526+
super_pirate = Class.new(Pirate) do
1527+
self.table_name = "pirates"
1528+
has_many :great_treasures, class_name: "Treasure", foreign_key: "looter_id", autosave: true
1529+
1530+
def self.name
1531+
"SuperPirate"
1532+
end
1533+
end
1534+
@pirate = super_pirate.create(catchphrase: "Don' botharrr talkin' like one, savvy?")
1535+
treasure = @pirate.great_treasures.create!(name: "gold")
1536+
estimate = treasure.price_estimates.create!(price: 1)
1537+
estimate.update_columns(price: "not a number")
1538+
1539+
assert_not_predicate estimate, :valid?
1540+
1541+
treasures = @pirate.great_treasures.eager_load(:price_estimates).to_a
1542+
treasures.first.price_estimates.first.price = "not a price"
1543+
new_pirate = super_pirate.new(
1544+
catchphrase: "Arr",
1545+
great_treasures: treasures,
1546+
)
1547+
1548+
assert_raises(ActiveRecord::RecordInvalid) do
1549+
new_pirate.save!
1550+
end
1551+
assert_equal(["Great treasures price estimates price is not a number"], new_pirate.errors.full_messages)
1552+
end
1553+
1554+
def test_should_be_valid_on_habtm_when_any_record_in_the_association_chain_is_invalid_but_was_not_changed
1555+
treasure = @pirate.treasures.create!(name: "gold")
1556+
estimate = treasure.price_estimates.create!(price: 1)
1557+
estimate.update_columns(price: "not a number")
1558+
1559+
assert_not_predicate estimate, :valid?
1560+
1561+
treasures = @pirate.treasures.eager_load(:price_estimates).to_a
1562+
new_pirate = Pirate.new(
1563+
catchphrase: "Arr",
1564+
treasures: treasures,
1565+
)
1566+
1567+
assert_nothing_raised do
1568+
new_pirate.save!
1569+
end
1570+
end
1571+
15041572
def test_a_child_marked_for_destruction_should_not_be_destroyed_twice_while_saving_habtm
15051573
@pirate.parrots.create!(name: "parrots_1")
15061574

activerecord/test/cases/nested_attributes_test.rb

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1206,12 +1206,19 @@ def self.name; "Guitar"; end
12061206
end
12071207

12081208
class TestNestedAttributesWithExtend < ActiveRecord::TestCase
1209-
setup do
1210-
Pirate.accepts_nested_attributes_for :treasures
1211-
end
1212-
12131209
def test_extend_affects_nested_attributes
1214-
pirate = Pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
1210+
super_pirate = Class.new(ActiveRecord::Base) do
1211+
self.table_name = "pirates"
1212+
1213+
has_many :treasures, as: :looter, extend: Pirate::PostTreasuresExtension
1214+
self.accepts_nested_attributes_for :treasures
1215+
1216+
def self.name
1217+
"SuperPirate"
1218+
end
1219+
end
1220+
1221+
pirate = super_pirate.create!(catchphrase: "Don' botharrr talkin' like one, savvy?")
12151222
pirate.treasures_attributes = [{ id: nil }]
12161223
assert_equal "from extension", pirate.treasures[0].name
12171224
end

activerecord/test/models/treasure.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ class Treasure < ActiveRecord::Base
66
# No counter_cache option given
77
belongs_to :ship
88

9-
has_many :price_estimates, as: :estimate_of
9+
has_many :price_estimates, as: :estimate_of, autosave: true
1010
has_and_belongs_to_many :rich_people, join_table: "peoples_treasures", validate: false
1111

1212
accepts_nested_attributes_for :looter

0 commit comments

Comments
 (0)