How to trigger action after many-to-many association has been destroyed? - ruby-on-rails

In my Rails 4 app I have the following models:
class Invoice < ActiveRecord::Base
has_many :allocations
has_many :payments, :through => :allocations
end
class Allocation < ActiveRecord::Base
belongs_to :invoice
belongs_to :payment
end
class Payment < ActiveRecord::Base
has_many :allocations, :dependent => :destroy
has_many :invoices, :through => :allocations
after_save :update_invoices
after_destroy :update_invoices # won't work
private
def update_invoices
invoices.each do |invoice|
invoice.save
end
end
end
The problem is that I need to update an invoice when one of its payments gets destroyed.
The update_invoices callback above obviously can't ever get triggered because at the time it gets called the connection with the invoice has already been destroyed.
So how can this be done?
Right now, I am doing this in my PaymentsController:
def destroy
#payment.destroy
current_user.invoices.each do |invoice|
invoice.save
end
...
end
However, this is very expensive of course because it goes through each and every invoice that a user has.
What might be a better alternative to this?
Thanks for any feedback.

One solution would be to grab the invoices before destroying the payment instance. Its add a bit more logic to the Controller however, but this is where the intent of both actions ( destroy payment and update invoices ) originate. It also reduces the iteration to just those invoices affected by the destroyed payment.
def destroy
invoices = #payment.invoices
#payment.destroy
invoices.each do |invoice|
invoice.save
end
...
end
Presumably you are overriding the save method of the Invoice model ( or have a callback on that as well), though I would choose a more explicit method for this intent. For example, removed_payment could be a method to handle this specific scenario and update the appropriate attributes - outstanding_amount and payment_status, etc.
def destroy
invoices = #payment.invoices
#payment.destroy
invoices.map(&:removed_payment)
...
end

The problem is that the associated allocation is also destroyed when destroying the payment. If you move the invoice updating to the Allocation model instead it will work as intended.
class Allocation < ActiveRecord::Base
belongs_to :invoice
belongs_to :payment
after_destroy :update_invoice
def update_invoice
if destroyed?
invoice.save!
end
end
end
Here's a Rails 4.1 test project with tests for this:
https://github.com/infused/update_parent_after_destroy

Related

How to detect changes in has_many through association?

I have the following models.
class Company < ApplicationRecord
has_many :company_users
has_many :users, :through => :company_users
after_update :do_something
private
def do_something
# check if users of the company have been updated here
end
end
class User < ApplicationRecord
has_many :company_users
has_many :companies, :through => :company_users
end
class CompanyUser < ApplicationRecord
belongs_to :company
belongs_to :user
end
Then I have these for the seeds:
Company.create :name => 'Company 1'
User.create [{:name => 'User1'}, {:name => 'User2'}, {:name => 'User3'}, {:name => 'User4'}]
Let's say I want to update Company 1 users, I will do the following:
Company.first.update :users => [User.first, User.second]
This will run as expected and will create 2 new records on CompanyUser model.
But what if I want to update again? Like running the following:
Company.first.update :users => [User.third, User.fourth]
This will destroy the first 2 records and will create another 2 records on CompanyUser model.
The thing is I have technically "updated" the Company model so how can I detect these changes using after_update method on Company model?
However, updating an attribute works just fine:
Company.first.update :name => 'New Company Name'
How can I make it work on associations too?
So far I have tried the following but no avail:
https://coderwall.com/p/xvpafa/rails-check-if-has_many-changed
Rails: if has_many relationship changed
Detecting changes in a rails has_many :through relationship
How to determine if association changed in ActiveRecord?
Rails 3 has_many changed?
There is a collection callbacks before_add, after_add on has_many relation.
class Project
has_many :developers, after_add: :evaluate_velocity
def evaluate_velocity(developer)
#non persisted developer
...
end
end
For more details: https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Association+callbacks
You can use attr_accessor for this and check if it changed.
class Company < ApplicationRecord
attr_accessor :user_ids_attribute
has_many :company_users
has_many :users, through: :company_users
after_initialize :assign_attribute
after_update :check_users
private
def assign_attribute
self.user_ids_attribute = user_ids
end
def check_users
old_value = user_ids_attribute
assign_attribute
puts 'Association was changed' unless old_value == user_ids_attribute
end
end
Now after association changed you will see message in console.
You can change puts to any other method.
I have the feelings you are asking the wrong question, because you can't update your association without destroy current associations. As you said:
This will destroy the first 2 records and will create another 2 records on CompanyUser model.
Knowing that I will advice you to try the following code:
Company.first.users << User.third
In this way you will not override current associations.
If you want to add multiple records once try wrap them by [ ] Or ( ) not really sure which one to use.
You could find documentation here : https://guides.rubyonrails.org/association_basics.html#has-many-association-reference
Hope it will be helpful.
Edit:
Ok I thought it wasn't your real issue.
Maybe 2 solutions:
#1 Observer:
what I do it's an observer on your join table that have the responsability to "ping" your Company model each time a CompanyUser is changed.
gem rails-observers
Inside this observer call a service or whatever you like that will do what you want to do with the values
class CompanyUserObserver < ActiveRecord::Observer
def after_save(company_user)
user = company_user.user
company = company_user.company
...do what you want
end
def before_destroy(company_user)
...do what you want
end
end
You can user multiple callback in according your needs.
#2 Keep records:
It turn out what you need it keep records. Maybe you should considerate use a gem like PaperTrail or Audited to keep track of your changes.
Sorry for the confusion.

Rails 5 dependent: :destroy doesn't work

I have the following classes:
class Product < ApplicationRecord
belongs_to :product_category
def destroy
puts "Product Destroy!"
end
end
class ProductCategory < ApplicationRecord
has_many :products, dependent: :destroy
def destroy
puts "Category Destroy!"
end
end
Here, I am trying to override the destroy method where I eventually want to do this:
update_attribute(:deleted_at, Time.now)
When I run the following statement in Rails console: ProductCategory.destroy_all I get the following output
Category Destroy!
Category Destroy!
Category Destroy!
Note: I have three categories and each category has more than one Products. I can confirm it by ProductCategory.find(1).products, which returns an array of products. I have heard the implementation is changed in Rails 5. Any points on how I can get this to work?
EDIT
What I eventually want is, to soft delete a category and all associated products in one go. Is this possible? Or will ave to iterate on every Product object in a before destroy callback? (Last option for me)
You should call super from your destroy method:
def destroy
super
puts "Category destroy"
end
But I definitely wouldn't suggest that you overide active model methods.
So this is how I did it in the end:
class Product < ApplicationRecord
belongs_to :product_category
def destroy
run_callbacks :destroy do
update_attribute(:deleted_at, Time.now)
# return true to escape exception being raised for rollback
true
end
end
end
class ProductCategory < ApplicationRecord
has_many :products, dependent: :destroy
def destroy
# run all callback around the destory method
run_callbacks :destroy do
update_attribute(:deleted_at, Time.now)
# return true to escape exception being raised for rollback
true
end
end
end
I am returning true from the destroy does make update_attribute a little dangerous but I am catching exceptions at the ApplicationController level as well, so works well for us.

How to sum up all associated records from join table in Ruby on Rails?

I have the following models in my Ruby on Rails app :
class Invoice < ActiveRecord::Base
has_many :allocations
has_many :payments, :through => :allocations
end
class Allocation < ActiveRecord::Base
belongs_to :invoice
belongs_to :payment
end
class Payment < ActiveRecord::Base
has_many :allocations, :dependent => :destroy
has_many :invoices, :through => :allocations
end
My problem is that in the Allocation class I would like to use the total_amount of all associated invoices, ideally in a before_save callback.
This isn't possible right now, however, because at the time an allocation object gets saved it is only associated with one particular invoice object.
How can this be done with a minimum of database queries?
Invoice.where(asdfasdfasdf).map(&:allocations).flatten.map(&:total_amount).compact.inject(:+)
Because this is rails the database call is nothing. To sum up an array of numbers you can use this:
ary = [0,12,2,6,nil]
ary.compact.inject(:+)
#=> 20
You could clean this up a bit:
class Invoice
#...
def total_amount
allocations.map(&:total_amount).inject(:+) #throws error if there is 1 nil 'total_amount data' value
end
def self.sum_allocation_amounts(sql)
where(sql).map(&:total_amount).inject(:+)
end
end
Its not possible to call self.map(&:allocations) inside of an Invoice class method without errors so I'm passing in the some basic sql as a workaround. Ideally I'd make it possible to directly call this method on a daisy chain of activerecord where calls for Invoice but that's not working out for me right now ("undefined method map' for Class")
Invoice.sum_allocation_amounts("democol = 'demo params'")

Active Relation: Retrieving records through an association?

I have the following models:
class User < ActiveRecord::Base
has_many :survey_takings
end
class SurveyTaking < ActiveRecord::Base
belongs_to :survey
def self.surveys_taken # must return surveys, not survey_takings
where(:state => 'completed').map(&:survey)
end
def self.last_survey_taken
surveys_taken.maximum(:position) # that's Survey#position
end
end
The goal is to be able to call #user.survey_takings.last_survey_taken from a controller. (That's contrived, but go with it; the general goal is to be able to call class methods on #user.survey_takings that can use relations on the associated surveys.)
In its current form, this code won't work; surveys_taken collapses the ActiveRelation into an array when I call .map(&:survey). Is there some way to instead return a relation for all the joined surveys? I can't just do this:
def self.surveys_taken
Survey.join(:survey_takings).where("survey_takings.state = 'completed'")
end
because #user.survey_takings.surveys_taken would join all the completed survey_takings, not just the completed survey_takings for #user.
I guess what I want is the equivalent of
class User < ActiveRecord::Base
has_many :survey_takings
has_many :surveys_taken, :through => :survey_takings, :source => :surveys
end
but I can't access that surveys_taken association from SurveyTaking.last_survey_taken.
If I'm understanding correctly you want to find completed surveys by a certain user? If so you can do:
Survey.join(:survey_takings).where("survey_takings.state = 'completed'", :user => #user)
Also it looks like instead of:
def self.surveys_taken
where(:state => 'completed').map(&:survey)
end
You may want to use scopes:
scope :surveys_taken, where(:state => 'completed')
I think what I'm looking for is this:
class SurveyTaking < ActiveRecord::Base
def self.surveys_taken
Survey.joins(:survey_takings).where("survey_takings.state = 'completed'").merge(self.scoped)
end
end
This way, SurveyTaking.surveys_taken returns surveys taken by anyone, but #user.survey_takings.surveys_taken returns surveys taken by #user. The key is merge(self.scoped).
Waiting for further comments before I accept..

callbacks on active record associations

I have a vacation approval model that has_many :entries is there a way that if I destroy one of those entries to have the rest destroyed? I also want to send one email if they are, but not one for each entry. Is there a way to observe changes to the collection as a whole?
A callback probably isn't a good choice because:
class Entry < ActiveRecord::Base
def after_destroy
Entry.where(:vacation_id => self.vacation_id).each {|entry| entry.destroy}
end
end
would produce some bad recursion.
It could be that you should do it in the controller:
class EntriesController < ApplicationController
def destroy
#entry = Entry.find(params[:id])
#entries = Entry.where(:vacation_id => #entry.vacation_id).each {|entry| entry.destroy}
#send email here
...
end
end
You can use the before_destroy callback.
class VacationRequest < ActiveRecord::Base
has_many :entries
end
class Entry < ActiveRecord::Base
belongs_to :vacation_request
before_destroy :destroy_others
def destroy_others
self.vacation_request.entries.each do |e|
e.mark_for_destruction unless e.marked_for_destruction?
end
end
end
Definitely test that code before you use it on anything important, but it should give you some direction to get started.
I think this ought to work:
class Entry < ActiveRecord::Base
belongs_to :vacation_request, :dependent => :destroy
# ...
end
class VacationApproval < ActiveRecord::Base
has_many :entries, :dependent => :destroy
# ...
end
What should happen is that when an Entry is destroyed, the associated VacationApproval will be destroyed, and subsequently all of its associated Entries will be destroyed.
Let me know if this works for you.
So What i ended up doing is
class VacationApproval < ActiveRecord::Base
has_many :entries , :conditions => {:job_id => Job.VACATION.id }, :dependent => :nullify
class Entry < ActiveRecord::Base
validates_presence_of :vacation_approval_id ,:if => lambda {|entry| entry.job_id == Job.VACATION.id} , :message => "This Vacation Has Been Canceled. Please Delete These Entries."
and then
#entries.each {|entry| entry.destroy if entry.invalid? }
in the index action of my controller.
and
`raise "Entries are not valid, please check them and try again ( Did you cancel your vacation? )" if #entries.any? &:invalid?`
in the submit action
The problem with deleting the others at the same time is if my UI makes 10 Ajax calls to selete 10 rows, and it deletes all of them the first time I end up with 9 unahandled 404 responses, which was undesirable.
Since I don't care it they remain there, as long as the Entry cannot be submitted its OK.
This was the easiest / safest / recursion friendly way for me, but is probably not the best way. Thanks for all your help!
To anyone curious/ seeking info
I ended up solving this later by setting The Vacation APProval model like this
class VacationApproval < ActiveRecord::Base
has_many :entries , :conditions => {:job_id => Job.VACATION.id }, :dependent => :delete_all
end
and My Entry Model like this
class Entry < ActiveRecord::Base
after_destroy :cancel_vacation_on_destory
def cancel_vacation_on_destory
if !self.vacation_approval.nil?
self.vacation_approval.destroy
end
end
end
Using :delete_all does not process callbacks, it just deletes them

Resources