Always validate record if validating a virtual attribute

Fixes #23645

When you're using an `attr_accessor` for a record instead of an
attribute in the database there's no way for the record to know if it
has `changed?` unless you tell it `attribute_will_change!("attribute")`.

The change made in 27aa4dd updated validations to check if a record was
`changed?` or `marked_for_destruction?` or not `persisted?`. It did not
take into account virtual attributes that do not affect the model's
dirty status.

The only way to fix this is to always validate the record if the
attribute does not belong to the set of attributes the record expects
(in `record.attributes`) because virtual attributes will not be in that
hash.

I think we should consider deprecating this particular behavior in the
future and requiring that the user mark the record dirty by noting that
the virtual attribute will change. Unfortunately this isn't easy because
we have no way of knowing that you did the "right thing" in your
application by marking it dirty and will get the deprecation warning
even if you are doing the correct thing.

For now this restores expected behavior when using a virtual attribute
by always validating the record, as well as adds tests for this case.

I was going to add the `!record.attributes.include?(attribute)` to the
`should_validate?` method but `uniqueness` cannot validate a virtual
attribute with nothing to hold on to the attribute. Because of this
`should_validate?` was about to become a very messy method so I decided
to split them up so we can handle it specifically for each case.
This commit is contained in:
eileencodes 2016-02-20 09:15:39 -05:00
parent 3156a7692c
commit 6f15b276cb
7 changed files with 55 additions and 3 deletions

@ -167,6 +167,13 @@ def check_validity!
def should_validate?(record) # :nodoc:
!record.persisted? || record.changed? || record.marked_for_destruction?
end
# Always validate the record if the attribute is a virtual attribute.
# We have no way of knowing that the record was changed if the attribute
# does not exist in the database.
def unknown_attribute?(record, attribute) # :nodoc:
!record.attributes.include?(attribute.to_s)
end
end
# +BlockValidator+ is a special +EachValidator+ which receives a block on initialization

@ -2,7 +2,7 @@ module ActiveRecord
module Validations
class AbsenceValidator < ActiveModel::Validations::AbsenceValidator # :nodoc:
def validate_each(record, attribute, association_or_value)
return unless should_validate?(record)
return unless should_validate?(record) || unknown_attribute?(record, attribute)
if record.class._reflect_on_association(attribute)
association_or_value = Array.wrap(association_or_value).reject(&:marked_for_destruction?)
end

@ -2,7 +2,7 @@ module ActiveRecord
module Validations
class LengthValidator < ActiveModel::Validations::LengthValidator # :nodoc:
def validate_each(record, attribute, association_or_value)
return unless should_validate?(record) || associations_are_dirty?(record)
return unless should_validate?(record) || unknown_attribute?(record, attribute) || associations_are_dirty?(record)
if association_or_value.respond_to?(:loaded?) && association_or_value.loaded?
association_or_value = association_or_value.target.reject(&:marked_for_destruction?)
end

@ -2,7 +2,7 @@ module ActiveRecord
module Validations
class PresenceValidator < ActiveModel::Validations::PresenceValidator # :nodoc:
def validate_each(record, attribute, association_or_value)
return unless should_validate?(record)
return unless should_validate?(record) || unknown_attribute?(record, attribute)
if record.class._reflect_on_association(attribute)
association_or_value = Array.wrap(association_or_value).reject(&:marked_for_destruction?)
end

@ -72,4 +72,18 @@ def test_does_not_validate_if_parent_record_is_validate_false
assert man.valid?
end
end
def test_validates_absence_of_virtual_attribute_on_model
repair_validations(Interest) do
Interest.send(:attr_accessor, :token)
Interest.validates_absence_of(:token)
interest = Interest.create!(topic: 'Thought Leadering')
assert interest.valid?
interest.token = 'tl'
assert interest.invalid?
end
end
end

@ -74,4 +74,20 @@ def test_does_not_validate_length_of_if_parent_record_is_validate_false
assert owner.valid?
assert pet.valid?
end
def test_validates_presence_of_virtual_attribute_on_model
repair_validations(Pet) do
Pet.send(:attr_accessor, :nickname)
Pet.validates_length_of(:name, minimum: 1)
Pet.validates_length_of(:nickname, minimum: 1)
pet = Pet.create!(name: 'Fancy Pants', nickname: 'Fancy')
assert pet.valid?
pet.nickname = ''
assert pet.invalid?
end
end
end

@ -80,4 +80,19 @@ def test_does_not_validate_presence_of_if_parent_record_is_validate_false
assert man.valid?
end
end
def test_validates_presence_of_virtual_attribute_on_model
repair_validations(Interest) do
Interest.send(:attr_accessor, :abbreviation)
Interest.validates_presence_of(:topic)
Interest.validates_presence_of(:abbreviation)
interest = Interest.create!(topic: 'Thought Leadering', abbreviation: 'tl')
assert interest.valid?
interest.abbreviation = ''
assert interest.invalid?
end
end
end