UPDATE: As it turns out, the below solution was totally rookie! The whole issue is still there, but the need to solve it with the use of update_all (which is a bit brute force and generally something to avoid for just updating a couple attributes on a single record, it avoids validations, etc, etc.), was not necessary. As it turns out, there are association callbacks. I'm kind of surprised I hadn't seen this or thought of it. But, there are callbacks to be notified when associated records are added, or removed (and both the before and after variations). So, instead, instead of setting the update_lowest_and_highest_rates as an after_update, specify an after_remove callback on the association:
has_many :fares, :after_remove => :update_lowest_and_highest_rates
Then implement the update method like:
def update_lowest_and_highest_rates
fares.reload
set_lowest_and_highest_rates
save
end
And note, set_lowest_and_highest_rates, is the same method as the create_lowest_and_highest_rates, just renamed. It's renamed because now you'd use before_validation or before_save or whatever is needed in your case, instead of the _on_create variant of that. The rest of the problems are still there (needing to refresh the fares association, and the general aspect of all this, etc.), but this is a much nicer solution I think.
I recently ran into some tricky issues related to deleting records on an association combined with ActiveRecord callbacks. The issue may seem like an edge case, but from googling for solutions, clearly it's not an uncommon need. An example will illustrate the problem...
I'll use a sort of pseudo-example from
DealBase. Let's take a flight "Deal" model. It
has_many "Fares", which are just prices for the deal from one airport to another. A Deal
accepts_nested_attributes_for Fares. This lets us edit a deal and all its fares in one form, etc. As part of this, you can select to delete a fare. We're using the standard mechanism of the _delete parameter and a checkbox, etc. So far, so typical.
In order to optimize searching and so on, we keep track of the lowest and highest rates from the fares in the deal record (i.e. we denormalize these values). To determine these low and high rates, we use an ActiveRecord callback to compute them. This is where the problems come in, at least when using nested attribute support. Deletion of records from the association (Fares) does not occur until after the Deal record is saved. What that means is that you can't calculate the low and high rates safely in a before_save callback, you need to use after_save. That is a bit of a pain in that it means you instantly are doing more DB calls to update those values since you can't do it as part of the primary update. For us, the rate of writes is small, so that isn't a big deal.
Now the second problem of course is that when you do update those values, you're going to cause another save of the record, and that will kick off the callbacks again. You've now created an infinite loop. There's no way that I know of, in Rails 2.3.x, to do an update without callbacks (older versions of Rails had
update_without_callbacks). As per the Rails Guide, there are however
a few methods that skip callbacks. One of those is
update_all. It's a bit of a brute force approach, but it is the solution I wound up with, at least for skipping callbacks.
The third problem is that within your after_save callback, your association won't be up to date if a deletion has been performed (it'll still contain the item that is marked for delete). This makes sense, given you are still working with the same instance of your primary object. Luckily you can just call reload on that. If we put all these things together, the final solution is relatively simple, although still feels a bit hacky:
class Deal < ActiveRecord::Base
has_many :fares
accepts_nested_attributes_for :fares
before_validation_on_create :create_lowest_and_highest_rates
after_update :update_lowest_and_highest_rates
...
protected
def update_lowest_and_highest_rates
fares.reload
low, high = (fares.minmax_by {|fare| fare.rate}).map(&:rate)
Deal.update_all({:lowest_rate => low, :highest_rate => high},
end
end
There are some downsides, such as validations not getting called on the denormalized values when doing the
update_all. You also likely need a before_validation_on_create to do the same functionality (if you have validations for the denormalized values) when first creating your Deal, in which case you don't have to worry about the delete situation. I'd love to hear of a better solution. Also, there is the
without_callbacks gem that provides some really nice syntactic sugar for this kind of thing, but hasn't been updated since 2008, and looks like it may not work with the latest versions of Rails.
Comments [0]