Rails ActiveRecord custom validator returns false but model.save is accepted without rollback - ruby-on-rails

i have a model which defines size ranges like 100m² to 200m². I wrote a validator:
class SizeRange < ActiveRecord::Base
validate :non_overlapping
def non_overlapping
lower_in_range = SizeRange.where(lower_bound: lower_bound..upper_bound)
upper_in_range = SizeRange.where(upper_bound: lower_bound..upper_bound)
if lower_in_range.present?
errors.add(:lower_bound, 'blablabla')
end
if upper_in_range.present?
errors.add(:upper_bound, 'blablabla')
end
end
end
My guess was, that when I try to save a new model which lower or upper bound appears to be in the range of an other SizeRange instance the validator would mark the model as invalid and the save action would be aborted.
What really happened is that my model got saved and assigned an id, but when I call model.valid? it returns false (So my validator seems to do what it should).
Is there anything I could have done wrong, or did I not understand how the validators work? Can I force the validator to abort the save action?
Another question:
Is there any way to enforce a constraint like that through database constraints? I think I would prefer a solution on database side.
Thanks for your help!

model.save
would be accepted silently and return false. It will not throw any Exception.
You should call:
model.save!
to fail with validations

You should return false after each errors.add to cancel the record saving

Turns out the problem was my not well-formed validator function.
The case I checked an which led to confusion:
SizeRange from 200 to 400 already in the database
Creating a new one between 200 and 400 like SizeRange.new({:lower_bound=>250, :upper_bound=>350})
So I expected that to be invalid (model.valid? -> false) but it was, of course, valid. So model.save did no rollback and AFTER that I tested on model.valid? which would NOW return false, as the newly saved instance would violate the constraint, because it was tested against itself.
So there were two problems:
model.valid? would also test against the same instance if it already had an id
the validator would not validate what I thought it would
So I ended up rewriting my validator function:
def non_overlapping
sr = id.present? ? SizeRange.where.not(id: id) : SizeRange
ranges = sr.all
lower_overlappings = ranges.map { |r| lower_bound.between?(r.lower_bound, r.upper_bound)}
upper_overlappings = ranges.map { |r| upper_bound.between?(r.lower_bound, r.upper_bound)}
if lower_overlappings.any?
errors.add(:lower_bound, 'lower bla bla')
end
if upper_overlappings.any?
errors.add(:lower_bound, 'upper bla bla')
end
end
The first line handles the first problem and the rest handles the intended validation.
Thanks for your help and sorry for the confusion.

You should be using begin and rescue:
class SizeRange < ActiveRecord::Base
validate :non_overlapping
private
def non_overlapping
lower_in_range = SizeRange.where(lower_bound: lower_bound..upper_bound)
upper_in_range = SizeRange.where(upper_bound: lower_bound..upper_bound)
begin
raise("Lower Bound Met!") if lower_in_range.present?
rescue => ex
errors.add(:lower_bound, "#{ex.message} (#{ex.class})")
end
begin
raise("Lower Bound Met!") if upper_in_range.present?
rescue => ex
errors.add(:upper_bound, "#{ex.message} (#{ex.class})")
end
end
end

Related

Getting "original" object during a before_add callback in ActiveRecord (Rails 7)

I'm in the process of updating a project to use Ruby 3 and Rails 7. I'm running into a problem with some code that was working before, but isn't now. Here's (I think) the relevant parts of the code.
class Dataset < ActiveRecord::Base
has_and_belongs_to_many :tags, :autosave => true,
:before_add => ->(owner, change){ owner.send(:on_flag_changes, :before_add, change) }
before_save :summarize_changes
def on_flag_changes(method, tag)
before = tags.map(&:id)
after = before + [tag.id]
record_change('tags', before, after)
end
def record_change(field, before_val, after_val)
reset_changes
before_val = #change_hash[field][0] if #change_hash[field]
if before_val.class_method_defined? :sort
before_val = before_val.sort unless before_val.blank?
after_val = after_val.sort unless after_val.blank?
end
#change_hash[field] = [before_val, after_val]
end
reset_changes
if #change_hash.nil?
#change_notes = {}
#change_hash = {
tags: [tags.map(&:id), :undefined]
}
end
end
def has_changes_to_save?
super || !change_hash.reject { |_, v| v[1] == :undefined }.blank?
end
def changes_to_save
super.merge(change_hash.reject { |_, v| v[0] == v[1] || v[1] == :undefined })
end
def summarize_changes
critical_fields = %w[ tags ]
#change_notes = changes_to_save.keep_if { |key, _value| critical_fields.include? key } if has_changes_to_save?
self.critical_change = true unless #change_notes.blank?
end
There are more fields for this class, and some attr_accessors but the reason I'm doing it this way is because the tags list can change, which may not necessarily trigger a change in the default "changes_to_save" list. This will allow us to track if the tags have changed, and set the "critical_change" flag (also part of Dataset) if they do.
In previous Rails instances, this worked fine. But since the upgrade, it's failing. What I'm finding is that the owner passed into the :before_add callback is NOT the same object as the one being passed into the before_save callback. This means that in the summarize_changes method, it's not seeing the changes to the #change_hash, so it's never setting the critical_change flag like it should.
I'm not sure what changed between Rails 6 and 7 to cause this, but I'm trying to find a way to get this to work properly; IE, if something says dataset.tags = [tag1, tag2], when tag1 was previously the only association, then dataset.save should result in the critical_change flag being set.
I hope that makes sense. I'm hoping this is something that is an easy fix, but so far my looking through the Rails 7 documentations has not given me the information I need. (it may go without saying that #change_notes and #change_hash are NOT persisted in the database; they are there just to track changes prior to saving to know if the critical_change flag should be set.
Thanks!
Turns out in my case there was some weird caching going on; I'd forgotten to mention an "after_initialize" callback that was calling the reset method, but for some reason at the time it makes this call, it wasn't the same object as actually got loaded, but some association caching was going on with tags (it was loading the tags association with the "initialized" record, and it was being cached with the "final" record, so it was confusing some of the code).
Removing the tags bit from the reset method, and having it initialize the tag state the first time it tries to modify tags solved the problem. Not particularly fond of the solution, but it works, and that's what I needed for now.

Modifying ActiveRecord models before preventing deletion

Some records in my application have a DOI assigned to them and in that case they should not be deleted. Instead, they should have their description changed and be flagged when a user triggers their deletion. A way to do this, I thought, would be as follows in the relevant model:
before_destroy :destroy_validation
private
def destroy_validation
if metadata['doi'].blank?
# Delete as normal...
nil
else
# This is a JSON field.
modified_metadata = Marshal.load(Marshal.dump(metadata))
description = "Record does not exist anymore: #{name}. The record with identifier content #{doi} was invalid."
modified_metadata['description'] = description
modified_metadata['tombstone'] = true
update_column :metadata, modified_metadata
raise ActiveRecord::RecordNotDestroyed, 'Records with DOIs cannot be deleted'
end
end
This does indeed prevent deletion, but the record appears unchanged afterwards rather than having a modified description. Here's an example of a test:
test "records with dois are not deleted" do
record = Record.new(metadata: metadata)
record.metadata['doi'] = 'this_is_a_doi'
assert record.save
assert_raises(ActiveRecord::RecordNotDestroyed) { record.destroy! }
assert Record.exists?(record.id)
modified_record = Record.find(record.id)
puts "#{record.description}" # This is correctly modified as per the callback code.
puts "#{modified_record.description}" # This is the same as when the record was created.
end
I can only guess that Rails is rolling back the update_column due to an exception having been raised, though I may be mistaken. Is there anything I can do to prevent this?
save and destroy are automatically wrapped in a transaction
https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
So destroy fails, transactions is rolled back and you can't see updated column in tests.
You could try with after_rollback callback https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html#method-i-after_rollback
or do record.destroy check for record.errors, if found update record with method manually record.update_doi if record.errors.any?.
before_destroy :destroyable?
...
def destroyable?
unless metadata['doi'].blank?
errors.add('Doi is not empty.')
throw :abort
end
end
def update_doi
modified_metadata = Marshal.load(Marshal.dump(metadata))
description = "Record does not exist anymore: #{name}. The record with identifier content #{doi} was invalid."
modified_metadata['description'] = description
modified_metadata['tombstone'] = true
update_column :metadata, modified_metadata
end
Tip: use record.reload instead of Record.find(record.id).

Increment field within validator

I have a custom validator that checks if the user has entered the correct SMS code. When the user enters the wrong code I need to log the failed attempt and limit their retries to 3 per code.
I have created the following validator that works however the field is not being incremented.
def token_match
if token != User.find(user_id).verification_token
User.find(user_id).increment!(:verification_fails)
errors.add(:sms_code, "does not match")
end
end
The problem is as soon as I add the error the previous statement is rolled back. If I comment out the errors.add line then the increment works however there is no higher level validation performed.
Change your custom validator to be:
def token_match
if token != User.find(user_id).verification_token
errors.add(:sms_code, "does not match")
end
end
and add in your model after_validation callback to be like this:
after_validation: increase_fails_count
def increase_fails_count
unless self.errors[:sms_code].empty?
user = User.find_by(:id => user_id)
user.increment!(:verification_fails)
user.save
end
end
You can use #update_columns in your validator. It writes directly to db.
u = User.find(user_id)
u.update_columns(verification_fails: u.verification_fails + 1)
This worked for me. But if for some reason it doesn't work for you, maybe you can try running it in a new thread,which creates a new db connection:
Thread.new do
num = User.find(user_id).verification_fails
ActiveRecord::Base.connection_pool.with_connection { |con| con.exec_query("UPDATE users SET verification_fails = #{num} WHERE id = #{user_id}") }
end.join

Update fails first time, succeeds second time

We've got this object, #current_employer, that's acting a bit weird. Update fails the first time, succeeds the second.
(byebug) #current_employer.update(settings_params)
false
(byebug) #current_employer.update(settings_params)
true
Here's where we initialise it:
#current_employer = Employer.find(decoded_auth_token[:employer_id])
It's just a standard "find".
Current workaround:
if #current_employer.update(settings_params) || #current_employer.update(settings_params)
...
Anyone seen this before?
Update
Tracked it down to this line in a "before_save" call
# self.is_test = false if is_test.nil?
Seems like is_test is a reserved keyword?
Solved
The full callback, with the fix commented inline:
def set_default_values
self.has_accepted_terms = false if has_accepted_terms.nil?
self.live = true if live.nil?
self.account_name.downcase!
self.display_name ||= account_name
self.display_name ||= ""
self.contact_first_name ||= ""
self.contact_last_name ||= ""
self.phone_number ||= ""
self.is_test_account = false if is_test_account.nil?
true # FIX. The proceeding line was returning 'false', which was giving us a 'false' return value for the before_save callback, preventing the save.
end
Model
If it's failing in one instance and succeeding almost immediately afterwards, the typical issue is that you're passing incorrect / conflicting attributes to the model.
I would speculate that the settings_params you're sending have a value which is preventing the save from occurring. You alluded to this with your update:
# self.is_test = false if is_test.nil?
The way to fix this is to cut out any of the potentially erroneous attributes from your params hash:
def settings_params
params.require(:employer).permit(:totally, :safe, :attributes)
end
Your model should update consistently - regardless of what conditions are present. If it's failing, it means there'll be another problem within the model save flow.
--
Without seeing extra information, I'm unable to see what they may be
Update
A better way to set default values is as follows:
How can I set default values in ActiveRecord?
You may wish to use the attribute-defaults gem:
class Foo < ActiveRecord::Base
attr_default :age, 18
attr_default :last_seen do
Time.now
end
end

Rails - 'can't dump hash with default proc' during custom validation

I have 2 models. User and Want. A User has_many: Wants.
The Want model has a single property besides user_id, that's name.
I have written a custom validation in the Want model so that a user cannot submit to create 2 wants with the same name:
validate :existing_want
private
def existing_want
return unless errors.blank?
errors.add(:existing_want, "you already want that") if user.already_wants? name
end
The already_wants? method is in the User model:
def already_wants? want_name
does_want_already = false
self.wants.each { |w| does_want_already = true if w.name == want_name }
does_want_already
end
The validation specs pass in my model tests, but my feature tests fail when i try and submit a duplicate to the create action in the WantsController:
def create
#want = current_user.wants.build(params[:want])
if #want.save
flash[:success] = "success!"
redirect_to user_account_path current_user.username
else
flash[:validation] = #want.errors
redirect_to user_account_path current_user.username
end
end
The error I get: can't dump hash with default proc
No stack trace that leads to my code.
I have narrowed the issue down to this line:
self.wants.each { |w| does_want_already = true if w.name == want_name }
if I just return true regardless the error shows in my view as I would like.
I don't understand? What's wrong? and why is it so cryptic?
Thanks.
Without a stack trace (does it lead anywhere, or does it just not appear?) it is difficult to know what exactly is happening, but here's how you can reproduce this error in a clean environment:
# initialize a new hash using a block, so it has a default proc
h = Hash.new {|h,k| h[k] = k }
# attempt to serialize it:
Marshal.dump(h)
#=> TypeError: can't dump hash with default proc
Ruby can't serialize procs, so it wouldn't be able to properly reconstitute that serialized hash, hence the error.
If you're reasonably sure that line is the source of your trouble, try refactoring it to see if that solves the problem.
def already_wants? want_name
wants.any? {|want| want_name == want.name }
end
or
def already_wants? want_name
wants.where(name: want_name).count > 0
end

Resources