Related model doesnt update value in memory after update - ruby-on-rails

I have something like a job-hunting website. There are Position models and each position has many Offers. When one offer is accepted, the rest are rejected and the position is closed. My tests for Offer pass as expected, however, the tests for Position fail because is_accepted remains nil...
test "accept_offer flips is_accepted bits" do
p = FactoryGirl.create(:position_with_offers, offers: 3)
assert_nil e.offers[0].is_accepted # pass
assert_nil e.offers[1].is_accepted # pass
assert_nil e.offers[2].is_accepted # pass
assert_equal false, p.is_closed # pass
p.accept_offer(e.offers[0])
assert_equal true, p.is_closed # pass
assert_equal true, e.offers[0].is_accepted # fail, == nil
assert_equal false, e.offers[1].is_accepted # fail, == nil
assert_equal false, e.offers[2].is_accepted # fail, == nil
end
...and...
class Position < ActiveRecord::Base
has_many :offers, inverse_of: :position
def accept_offer(_offer)
offers.each do |o|
if o.id == _offer.id
o.accept
else
o.reject
end
o.reload
end
update({ is_closed: true })
end
end
...and...
class Offer < ActiveRecord::Base
def accept
update_column(:is_accepted, true)
end
def reject
update_column(:is_accepted, false)
end
end
I've read that .reload should be called to refresh the model. I've put reload on everything with no success. I've tried variations of update_all, update_attribute, update_column but the offers are always nil. Interestingly, if I puts the values in position.accept_offer, the correct values are rendered.
What do I need to do to get this test to pass?

Reload the offers.
assert_equal true, e.offers[0].reload.is_accepted # fail, == nil
assert_equal false, e.offers[1].reload.is_accepted # fail, == nil
assert_equal false, e.offers[2].reload.is_accepted # fail, == nil

Related

Rails Minitest one model validation causes ArgumentError: You need to supply at least one validation

In my Rails6 app I've got two model validations which I want to test by Minitest:
class Portfolio < ApplicationRecord
validates :name, :status, presence: true
validates :initial_return do |record, attr, value|
record.errors.add(attr, 'Add value between -100 and 100') unless value >= -100 && value <= 100
end
end
Minitest:
class PortfolioTest < ActiveSupport::TestCase
setup do
#portfolio = Portfolio.create(name: Faker::Bank.name)
end
test 'invalid PortfolioSpotlightFigure, does not fit the range (-100, 100)' do
#portfolio.initial_return = -101
assert_not #portfolio.valid?
#portfolio.initial_return = 101
assert_not #portfolio.valid?
#portfolio.initial_return = 50
assert #portfolio.valid?
end
context 'validations' do
should validate_presence_of(:name)
end
end
Minitest gives the same error for both cases:
ArgumentError: You need to supply at least one validation
But when I remove validation for :initial_return field from Portfolio model:
validates :initial_return do |record, attr, value|
record.errors.add(attr, 'Add value between -100 and 100') unless value >= -100 && value <= 100
the test will pass for the validate_presence_of(:name) which means that I incorrectly defined that validation. What did I missed?
You don't need to reinvent the wheel
class Portfolio < ApplicationRecord
validates :name, :status, presence: true
validates :initial_return,
numericality: {
greater_than_or_equal_to: -100,
less_than_or_equal_to: 100
}
end
And stop carpet bombing your validations in your tests. Test the actual validation and not if the entire object is valid/invalid which leads to false positives and negatives. For example:
test 'invalid PortfolioSpotlightFigure, does not fit the range (-100, 100)' do
#portfolio.initial_return = -101
# these will pass even if you comment out the validation on initial_return as
# status is nil
assert_not #portfolio.valid?
#portfolio.initial_return = 101
assert_not #portfolio.valid?
# Will fail because status is nil
#portfolio.initial_return = 50
assert #portfolio.valid?
end
As you can see the test failures will tell you nothing about why the model is valid/invalid.
Instead use one assertion per test and test the actual validation:
class PortfolioTest < ActiveSupport::TestCase
setup do
# you dont need to insert records into the db to test associations
#portfolio = Portfolio.new
end
test 'initial return over 100 is invalid' do
# arrange
#portfolio.initial_return = 200
# act
#portfolio.valid?
# assert
assert_includes(#portfolio.errors.full_messages, "Initial return must be less than or equal to 100")
end
test 'initial return below -100 is invalid' do
# arrange
#portfolio.initial_return = -200
# act
#portfolio.valid?
# assert
assert_includes(#portfolio.errors.full_messages, "Initial return must be greater than or equal to -100")
end
test 'an initial return between -100 and 100 is valid' do
# arrange
#portfolio.initial_return = 50
# act
#portfolio.valid?
# assert
refute(#portfolio.errors.has_key?(:intial_return))
end
# ...
end
With shoulda you should be able to use the validates_numericality_of matcher:
should validate_numericality_of(:initial_return).
is_greater_than_or_equal_to(-100).
is_less_than_or_equal_to(100)
#portfolio = Portfolio.create(name: Faker::Bank.name) in the setup block is expected to already fail.
I don't know if it leads to the actual error but you can't create the object when you don't provide the initial initial_return. Since it runs against the validation itself.
Because the test case for the numerical range runs you need to make sure that you initial object is valid.
That's why it didn't fail when you removed the initial_return validation because the setup block was successful without the validation. You were just looking at the wrong end.
So you either use build which does not persist the object in the database and does not run the validation initially
#portfolio = Portfolio.build(name: Faker::Bank.name)
or if in case you want to persist the object in the database you have to make sure the setup object is valid
#portfolio = Portfolio.create(name: Faker::Bank.name, initial_return: 50)

Rails Validation numbericality fails on form object

Related/Fixed: Ruby on Rails: Validations on Form Object are not working
I have the below validation..
validates :age, numericality: { greater_than_or_equal_to: 0,
only_integer: true,
:allow_blank => true
}
It is not required, if entered needs to be a number. I have noticed that if someone types in a word instead of a number, the field value changes to 0 after submit and passes validation. I would prefer it to be blank or the entered value.
Update:
Still no solution, but here is more information.
rspec test
it "returns error when age is not a number" do
params[:age] = "string"
profile = Registration::Profile.new(user, params)
expect(profile.valid?).to eql false
expect(profile.errors[:age]).to include("is not a number")
end
Failing Rspec Test:
Registration::Profile Validations when not a number returns error when age is not a number
Failure/Error: expect(profile.errors[:age]).to include("is not a number")
expected [] to include "is not a number"
2.6.5 :011 > p=Registration::Profile.new(User.first,{age:"string"})
2.6.5 :013 > p.profile.attributes_before_type_cast["age"]
=> "string"
2.6.5 :014 > p.age
=> 0
2.6.5 :015 > p.errors[:age]
=> []
2.6.5 :016 > p.valid?
=> true
#Form Object Registration:Profile:
module Registration
class Profile
include ActiveModel::Model
validates :age, numericality: { greater_than_or_equal_to: 0,
only_integer: true,
:allow_blank => true
}
attr_reader :user
delegate :age , :age=, to: :profile
def validate!
raise ArgumentError, "user cant be nil" if #user.blank?
end
def persisted?
false
end
def user
#user ||= User.new
end
def teacher
#teacher ||= user.build_teacher
end
def profile
#profile ||= teacher.build_profile
end
def submit(params)
profile.attributes = params.slice(:age)
if valid?
profile.save!
true
else
false
end
end
def self.model_name
ActiveModel::Name.new(self, nil, "User")
end
def initialize(user=nil, attributes={})
validate!
#user = user
end
end
end
#Profile Model:
class Profile < ApplicationRecord
belongs_to :profileable, polymorphic: true
strip_commas_fields = %i[age]
strip_commas_fields.each do |field|
define_method("#{field}=".intern) do |value|
value = value.gsub(/[\,]/, "") if value.is_a?(String) # remove ,
self[field.intern] = value
end
end
end
The interesting thing is that if move the validation to the profile model and check p.profile.errors, I see the expected result, but not on my form object. I need to keep my validations on my form object.
If the underlying column in the DB is a numeric type, then Rails castes the value. I assume this is done in [ActiveRecord::Type::Integer#cast_value][1]
def cast_value(value)
value.to_i rescue nil
end
Assuming model is a ActiveRecord model where age is a integer column:
irb(main):008:0> model.age = "something"
=> "something"
irb(main):009:0> model.age
=> 0
irb(main):010:0>
This is because submitting a form will always submit key value pairs, where the keys values are strings.
No matter if your DB column is a number, boolean, date, ...
It has nothing to do with the validation itself.
You can access the value before the type cast like so:
irb(main):012:0> model.attributes_before_type_cast["age"]
=> "something"
If your requirements dictate another behaviour you could do something like this:
def age_as_string=(value)
#age_as_string = value
self.age = value
end
def age_as_string
#age_as_string
end
And then use age_as_string in your form (or whatever). You can also add validations for this attribute, e.g.:
validates :age_as_string, format: {with: /\d+/, message: "Only numbers"}
You could also add a custom type:
class StrictIntegerType < ActiveRecord::Type::Integer
def cast(value)
return super(value) if value.kind_of?(Numeric)
return super(value) if value && value.match?(/\d+/)
end
end
And use it in your ActiveRecord class through the "Attributes API":
attribute :age, :strict_integer
This will keep the age attribute nil if the value you are trying to assign is invalid.
ActiveRecord::Type.register(:strict_integer, StrictIntegerType)
[1]: https://github.com/rails/rails/blob/fbe2433be6e052a1acac63c7faf287c52ed3c5ba/activemodel/lib/active_model/type/integer.rb#L34
Why don't you add validations in frontend? You can use <input type="number" /> instead of <input type="text" />, which will only accept number from the user. The way I see you explaining the issue, this is a problem to be resolved in the frontend rather than backend.
You can read more about it here: Number Type Input
Please let me know if this doesn't work for you, I will be glad to help you.

Rails: Adding to errors[:base] does not make record invalid?

In my Purchase model, I have a method that calculates the tax:
def calculate_tax
if self.shipping_address.state == State.new_york
corresponding_tax = Tax.find_by(zip_code: self.shipping_address.zip_code, state_id: self.shipping_address.state_id)
if corresponding_tax
self.tax = corresponding_tax.rate * (self.subtotal + shipping)
else
#HERE !!!
self.errors[:base] << "The zip code you have entered is invalid."
puts "errors = #{self.errors.full_messages}" #<-- this prints out the error in my log, so I know it's being run
end
else
self.tax = 0.00
end
end
This method is being called within this method:
def update_all_fees!
calculate_subtotal
calculate_shipping
calculate_tax #<-- being called here
calculate_total
save!
end
However, save! is saving the record successfully. Shouldn't it be throwing an exception? How would I make it so that save! fails when calculate_tax is in the second else block?
You can add custom validation methods with the validate directive. Here's may take on the code you posted:
class Purchase < ActiveRecord::Base
validate :new_york_needs_tax_record
def update_all_fees!
calculate_subtotal
calculate_shipping
calculate_tax
calculate_total
save!
end
private
def calculate_tax
if ships_to_new_york? && corresponding_tax
self.tax = corresponding_tax.rate * (self.subtotal + shipping)
elsif !ships_to_new_york?
self.tax = 0.00
else
self.tax = nil
end
end
def ships_to_new_york?
self.shipping_address.state == State.new_york
end
def corresponding_tax
Tax.find_by(zip_code: self.shipping_address.zip_code, state_id: self.shipping_address.state_id)
end
def new_york_need_tax_record
if ships_to_new_york? && !corresponding_tax
self.errors[:base] << "The zip code you have entered is invalid."
end
end
end
Edited for historical reasons. The first response didn't cover all scenarios.
But if you need to raise the error if there are any just do:
validate :taxes_scenario
def taxes_scenario
[Add any clause here that makes your scenario invalid]
end
So you can validate the taxes scenario and made sure your error is added properly.
Simply adding an error to the error list won't make the record fail. You have to have what's called a "validation" set up. There are some wonderful guides about it here that should help you through the process.
For example in this one you will probably want to add to your Tax model the following validation:
validates :zip_code, presence: true, numericality: true
once you have the validations set up, save! should automatically spit out an error when a model fails validation
In Rails 7:
class User < ApplicationRecord
validate :must_be_a_friend
def must_be_a_friend
if friend == false
# Does NOT work anymore
errors[:base] << 'Must be friends'
# DOES work
errors.add(:base, 'Must be friends')
end
end
end

Rails model.valid? flushing custom errors and falsely returning true

I am trying to add a custom error to an instance of my User model, but when I call valid? it is wiping the custom errors and returning true.
[99] pry(main)> u.email = "test#test.com"
"test#test.com"
[100] pry(main)> u.status = 1
1
[101] pry(main)> u.valid?
true
[102] pry(main)> u.errors.add(:status, "must be YES or NO")
[
[0] "must be YES or NO"
]
[103] pry(main)> u.errors
#<ActiveModel::Errors:[...]#messages={:status=>["must be YES or NO"]}>
[104] pry(main)> u.valid?
true
[105] pry(main)> u.errors
#<ActiveModel::Errors:[...]#messages={}>
If I use the validate method from within the model, then it works, but this specific validation is being added from within a different method (which requires params to be passed):
User
def do_something_with(arg1, arg2)
errors.add(:field, "etc") if arg1 != arg2
end
Because of the above, user.valid? is returning true even when that error is added to the instance.
In ActiveModel, valid? is defined as following:
def valid?(context = nil)
current_context, self.validation_context = validation_context, context
errors.clear
run_validations!
ensure
self.validation_context = current_context
end
So existing errors are cleared is expected. You have to put all your custom validations into some validate callbacks. Like this:
validate :check_status
def check_status
errors.add(:status, "must be YES or NO") unless ['YES', 'NO'].include?(status)
end
If you want to force your model to show the errors you could do something as dirty as this:
your_object = YourModel.new
your_object.add(:your_field, "your message")
your_object.define_singleton_method(:valid?) { false }
# later on...
your_object.valid?
# => false
your_object.errors
# => {:your_field =>["your message"]}
The define_singleton_method method can override the .valid? behaviour.
This is not a replacement for using the provided validations/framework. However, in some exceptional scenarios, you want to gracefully return an errd model. I would only use this when other alternatives aren't possible. One of the few scenarios I have had to use this approach is inside of a service object creating a model where some portion of the create fails (like resolving a dependent entity). It doesn't make sense for our domain model to be responsible for this type of validation, so we don't store it there (which is why the service object is doing the creation in the first place). However for simplicity of the API design it can be convenient to hang a domain error like 'associated entity foo not found' and return via the normal rails 422/unprocessible entity flow.
class ModelWithErrors
def self.new(*errors)
Module.new do
define_method(:valid?) { false }
define_method(:invalid?) { true }
define_method(:errors) do
errors.each_slice(2).with_object(ActiveModel::Errors.new(self)) do |(name, message), errs|
errs.add(name, message)
end
end
end
end
end
Use as some_instance.extend(ModelWithErrors.new(:name, "is gibberish", :height, "is nonsense")
create new concerns
app/models/concerns/static_error.rb
module StaticError
extend ActiveSupport::Concern
included do
validate :check_static_errors
end
def add_static_error(*args)
#static_errors = [] if #static_errors.nil?
#static_errors << args
true
end
def clear_static_error
#static_errors = nil
end
private
def check_static_errors
#static_errors&.each do |error|
errors.add(*error)
end
end
end
include the model
class Model < ApplicationRecord
include StaticError
end
model = Model.new
model.add_static_error(:base, "STATIC ERROR")
model.valid? #=> false
model.errors.messages #=> {:base=>["STATIC ERROR"]}
A clean way to achieve your needs is contexts, but if you want a quick fix, do:
#in your model
attr_accessor :with_foo_validation
validate :foo_validation, if: :with_foo_validation
def foo_validation
#code
end
#where you need it
your_object.with_foo_validation = true
your_object.valid?

How to update counter_cache when updating a model?

I have a simple relationship:
class Item
belongs_to :container, :counter_cache => true
end
class Container
has_many :items
end
Let's say I have two containers. I create an item and associate it with the first container. The counter is increased.
Then I decide to associate it with the other container instead. How to update the items_count column of both containers?
I found a possible solution at http://railsforum.com/viewtopic.php?id=39285 .. however I'm a beginner and I don't understand it. Is this the only way to do it?
It should work automatically. When you are updating items.container_id it will decreament old container's counter and increament new one. But if it isn't works - it is strange. You can try this callback:
class Item
belongs_to :container, :counter_cache => true
before_save :update_counters
private
def update_counters
new_container = Container.find self.container_id
old_container = Container.find self.container_id_was
new_container.increament(:items_count)
old_container.decreament(:items_count)
end
end
UPD
To demonstrate native behavior:
container1 = Container.create :title => "container 1"
#=> #<Container title: "container 1", :items_count: nil>
container2 = Container.create :title => "container 2"
#=> #<Container title: "container 2", :items_count: nil>
item = container1.items.create(:title => "item 1")
Container.first
#=> #<Container title: "container 1", :items_count: 1>
Container.last
#=> #<Container title: "container 1", :items_count: nil>
item.container = Container.last
item.save
Container.first
#=> #<Container title: "container 1", :items_count: 0>
Container.last
#=> #<Container title: "container 1", :items_count: 1>
So it should work without any hacking. From the box.
Modified it a bit to handle custom counter cache names
(Don't forget to add after_update :fix_updated_counter to the models using counter_cache)
module FixUpdateCounters
def fix_updated_counters
self.changes.each { |key, (old_value, new_value)|
# key should match /master_files_id/ or /bibls_id/
# value should be an array ['old value', 'new value']
if key =~ /_id/
changed_class = key.sub /_id$/, ''
association = self.association changed_class.to_sym
case option = association.options[ :counter_cache ]
when TrueClass
counter_name = "#{self.class.name.tableize}_count"
when Symbol
counter_name = option.to_s
end
next unless counter_name
association.klass.decrement_counter(counter_name, old_value) if old_value
association.klass.increment_counter(counter_name, new_value) if new_value
end
} end end
ActiveRecord::Base.send(:include, FixUpdateCounters)
For rails 3.1 users.
With rails 3.1, the answer doesn't work.
The following works for me.
private
def update_counters
new_container = Container.find self.container_id
Container.increment_counter(:items_count, new_container)
if self.container_id_was.present?
old_container = Container.find self.container_id_was
Container.decrement_counter(:items_count, old_container)
end
end
here is an approach that works well for me in similar situations
class Item < ActiveRecord::Base
after_update :update_items_counts, if: Proc.new { |item| item.collection_id_changed? }
private
# update the counter_cache column on the changed collections
def update_items_counts
self.collection_id_change.each do |id|
Collection.reset_counters id, :items
end
end
end
additional information on dirty object module http://api.rubyonrails.org/classes/ActiveModel/Dirty.html and an old video about them http://railscasts.com/episodes/109-tracking-attribute-changes and documentation on reset_counters http://apidock.com/rails/v3.2.8/ActiveRecord/CounterCache/reset_counters
Updates to #fl00r Answer
class Container
has_many :items_count
end
class Item
belongs_to :container, :counter_cache => true
after_update :update_counters
private
def update_counters
if container_id_changed?
Container.increment_counter(:items_count, container_id)
Container.decrement_counter(:items_count, container_id_was)
end
# other counters if any
...
...
end
end
I recently came across this same problem (Rails 3.2.3). Looks like it has yet to be fixed, so I had to go ahead and make a fix. Below is how I amended ActiveRecord::Base and utilize after_update callback to keep my counter_caches in sync.
Extend ActiveRecord::Base
Create a new file lib/fix_counters_update.rb with the following:
module FixUpdateCounters
def fix_updated_counters
self.changes.each {|key, value|
# key should match /master_files_id/ or /bibls_id/
# value should be an array ['old value', 'new value']
if key =~ /_id/
changed_class = key.sub(/_id/, '')
changed_class.camelcase.constantize.decrement_counter(:"#{self.class.name.underscore.pluralize}_count", value[0]) unless value[0] == nil
changed_class.camelcase.constantize.increment_counter(:"#{self.class.name.underscore.pluralize}_count", value[1]) unless value[1] == nil
end
}
end
end
ActiveRecord::Base.send(:include, FixUpdateCounters)
The above code uses the ActiveModel::Dirty method changes which returns a hash containing the attribute changed and an array of both the old value and new value. By testing the attribute to see if it is a relationship (i.e. ends with /_id/), you can conditionally determine whether decrement_counter and/or increment_counter need be run. It is essnetial to test for the presence of nil in the array, otherwise errors will result.
Add to Initializers
Create a new file config/initializers/active_record_extensions.rb with the following:
require 'fix_update_counters'
Add to models
For each model you want the counter caches updated add the callback:
class Comment < ActiveRecord::Base
after_update :fix_updated_counters
....
end
Here the #Curley fix to work with namespaced models.
module FixUpdateCounters
def fix_updated_counters
self.changes.each {|key, value|
# key should match /master_files_id/ or /bibls_id/
# value should be an array ['old value', 'new value']
if key =~ /_id/
changed_class = key.sub(/_id/, '')
# Get real class of changed attribute, so work both with namespaced/normal models
klass = self.association(changed_class.to_sym).klass
# Namespaced model return a slash, split it.
unless (counter_name = "#{self.class.name.underscore.pluralize.split("/")[1]}_count".to_sym)
counter_name = "#{self.class.name.underscore.pluralize}_count".to_sym
end
klass.decrement_counter(counter_name, value[0]) unless value[0] == nil
klass.increment_counter(counter_name, value[1]) unless value[1] == nil
end
}
end
end
ActiveRecord::Base.send(:include, FixUpdateCounters)
Sorry I don't have enough reputation to comment the answers.
About fl00r, I may see a problem if there is an error and save return "false", the counter has already been updated but it should have not been updated.
So I'm wondering if "after_update :update_counters" is more appropriate.
Curley's answer works but if you are in my case, be careful because it will check all the columns with "_id". In my case it is automatically updating a field that I don't want to be updated.
Here is another suggestion (almost similar to Satish):
def update_counters
if container_id_changed?
Container.increment_counter(:items_count, container_id) unless container_id.nil?
Container.decrement_counter(:items_count, container_id_was) unless container_id_was.nil?
end
end

Resources