Tuesday, October 5, 2010

Save Using ActiveRecord Without Rolling Back If Callbacks Fail

I recently came across an interesting situation when using Ruby on Rails. I wanted to make all of my models so that they could not be destroyed. The plan was to make all of the database records be "soft deleted", where each table had a column storing whether a record was "deleted". This was easily accomplished. Then, I wanted to prevent the records from being deleted. In my models, all I had to do was add a before_destroy callback and return false:

class Model < ActiveRecord::Base
  before_destroy :beforeDestroyMethod

  def beforeDestroyMethod
    return false
  end
end

All operations on the model instances are contained within transactions. If any of the before_* callbacks return false, the transaction is rolled back. This is all well and good, provided you don't want to insert a record in a different table in the before_destroy method. I happened to want to log any attempt to destroy records from the database, and I wanted to use the database to store the log entry:

class Model < ActiveRecord::Base
  before_destroy :beforeDestroyMethod

  def beforeDestroyMethod
    log = LogEntry.new
    log.action_type = "Destroy Object"
    log.save
    return false
  end
end

Unfortunately, when the before_destroy method returned false, it also rolled back the log entry insertion.

After looking around, I found a partial answer. If you call 'establish_connection' in your Rails model, you will get a new database connection for that class and all classes that inherit from it:

class LogEntry < ActiveRecord::Base
  establish_connection ENV['RAILS_ENV']
end

I am using ENV['RAILS_ENV'] so that it creates a connection for the development or the production database, depending on which environment it's in. You can also specify a different database entirely.

My other models can now work as I had intended before:

class Model < ActiveRecord::Base
  before_destroy :beforeDestroyMethod

  def beforeDestroyMethod
    log = LogEntry.new
    log.action_type = "Destroy Object"
    log.save
    return false
  end
end
So, that seems to have solved the problem. Well, mostly. What happens if you want to add a log entry if someone tries to destroy a log entry? You have the exact same problem as before. This time, I used a different trick for this one case:

class LogEntry < ActiveRecord::Base
  establish_connection ENV['RAILS_ENV']

  before_destroy :beforeDestroyMethod

  def beforeDestroyMethod
    query = "insert into log_entries(action_type) values ("Destroy Log Entry");"
    ActiveRecord::Base.connection_pool.with_connection do |conn|
      conn.execute(query)
    end
    return false
  end
end

This time, I just grab a database connection from the connection pool. Unfortunately, you have to utilize raw queries to use the connection, but it gets the job done.

Alternatively, I could have also got a connection by using the checkout method:

class LogEntry < ActiveRecord::Base
  establish_connection ENV['RAILS_ENV']

  before_destroy :beforeDestroyMethod

  def beforeDestroyMethod
    query = "insert into log_entries(action_type) values ("Destroy Log Entry");"
    conn = ActiveRecord::Base.connection_pool.checkout
    conn.execute(query)
    ActiveRecord::Base.connection_pool.checkin(conn)
    return false
  end
end

Thus, my problem was solved.