Search code examples
ruby-on-railsrubycallbackassociated-object

How to fetch previous values of associated records?


When working with many-to-many relationships, I need to maintain a log file recording the changed values. Using the before_save and after_save callbacks works fine for the main (has_many) model itself but in the before_save callback the associated (belongs_to) records appear to be updated already! It seems rather odd to see that some parts of the data already have been updated before the 'before_save' callback is called.

Also, using callbacks in the associated model reveals that there is no before_destroy executed. Only the before_save gets called and shows the new values. I also tried the :prepend => :true option but that didn't gave other results.

When turning on SQL logging before the actual save in the main (has_many) model, I can see Rails is fetching the associated records, determines the differences and deletes the surplus record(s). The before_destroy of the associated model is not called. It then calls the before_save of the associated model and inserts the new ones (if any) and commits the transaction. This is all done JUST BEFORE the before_save of the main model.

Does anyone know how to fetch the associated records before they are changed? I would expect the before_destroy of the associated model would get called and let me handle it.


Solution

  • For clarity sake, lets give some extended information:

    class Book < ActiveRecord::Base
      unloadable
      has_many :titles, dependent: :destroy
      has_many :authors, :through => :titles
      accepts_nested_attributes_for :authors
    
      before_save :pre_save
      after_save  :post_save
      before_destroy :pre_delete
    
      def pre_save
        @nr = self.new_record?
      end
    
      def pre_save
        changed_values = []
        if @nr
          changed_values.push "New record created"
        else 
          self.changes.each do |field, cvs|
            changes.push("#{field} : #{cvs[0]} => #{cvs[1]}")
          end
        end
        if changes.length > 0
          BookLog.create(:book_id => self.id, :changed_values => changes.join(', '))
        end
      end
    
      def pre_delete
        BookLog.create(:book_id => self.id, :changed_values => "Deleted: #{self.name}")
      end
    end
    
    class Title < ActiveRecord::Base
      unloadable
      belongs_to :book
      belongs_to :author
    end
    
    class Author < ActiveRecord::Base
      unloadable
      has_many :titles, dependent: :destroy
      has_many :books, :through => :titles
      accepts_nested_attributes_for :books
    end
    
    class BooksController < ApplicationController
    
      def edit
        book = Book.find(params[:book][:id])
        book.name = .....
        ===> Here the old values are still available <==== 
        book.author_ids = params[:book][:author_ids] 
        ===> Now the new values are written to the database! <==== 
        book.save!
      end
    end
    

    Changes to the Book record are perfectly logged. But there is no way to fetch the changed associated values for author_ids. A before_destroy callback in Title was not called, the after_save was.

    I checked this with enabling the SQL logging just before the assignment of the new author_ids to the edited record. I could see that Rails determines the differences between the existing and new associated values, deletes the surplus form the Titles table and insert the extra ones (if any)

    I solved it by moving the logging for the changes in Titles to the Books controller by comparing the old with the new values:

         o_authors = book.author_ids
         n_authors = params[:book][:author_ids].collect {|c| c.to_i}
         diff = o_authors - n_authors | n_authors - o_authors
         if !diff.empty?
           changed_values = []
           (o_authors - n_authors).each do |d|
             changed_values.push("Removed Author: #{Author.find(d).name}")
           end
           (n_authors - o_authors).each do |d|
             changed_values.push("Added Author: #{Author.find(d).name}")
           end
           BookLog.create(:book_id => book.id, :changed_values => changed_values)
         end
         book.author_ids = params[:book][:author_ids]
         book.save!
    

    Like I said, it works but IMHO it does not reflect the Rails way of doing things. I would have expected to get the previous author_ids in the same way as any other Book attribute.