Enhancing Papertrail to include versioning of Associated Models

If you’re here, I assume you already know about the PaperTrail gem. If not, check out its official Github page. It is the leading Ruby gem for ActiveRecord versioning. It can help us to see how a model looked at any stage in its lifecycle, revert it to any version, or restore it after it has been destroyed.

We have been using Papertrail in our application for a very long time, and it was fulfilling its duties splendidly. Until recently when our product team decided to roll out an activity feed for our customers to keep them informed about the progress of their travel requests and the changes being made to them. Our first instinct to implement the activity feed feature was to capitalize on the versions records maintained by Papertrail. But to our surprise we hit a wall this time with Papertrail. Like Rails, Papertrail too is a very opinionated Ruby gem. It is very focused on doing one thing and it does it very well. Papertrail maintains versions of a single ActiveRecord model only. But in our case this wasn’t enough. To successfully implement the activity feed we needed versioning of associated models as well. To use Papertrail in a way it was not designed to, we were left with no choice but to build upon the gem’s default functionality and add behaviours that we wanted.

Our Objective
——————-

Imagine we have these two models.

class Company < ActiveRecord::Base
  has_many :employees
end

class Employee < ActiveRecord::Base
  belongs_to :company
end

In its basic form Papertrail allows us to find the versions of an object in the following way.

company = Company.find 42
company.versions

This returns an array of PaperTrail::Version objects.

Suppose we have a particular version object of a company with us, Like

ver = company.versions.last

and we want to find out the versions of Employee’s associated with this particular version of company. We won’t be able to do this with the functionality that Papertrail provides out-of-the-box. Thus our aim here is to construct such a mechanism that we are able to do something like the following :

ver.get_associated_child_versions("Employee")

which will return the Employee versions associated with the version ver of company.

Implementation
———————-

To write our additional code we need to create a new ‘CustomVersion‘ class which will inherit from PaperTrail::Version class, and direct our models to use this new class for versioning.

class CustomVersion < PaperTrail::Version
end

class Company < ActiveRecord::Base
  has_paper_trail :class_name => 'CustomVersion'
end

class Employee < ActiveRecord::Base
  has_paper_trail :class_name => 'CustomVersion'
end

Now whenever company.versions is called it will return an array of CustomVersion objects and not PaperTrail::Version objects.

To store the relationship between the versions of the parent class and versions of the child class a join table needs to be created. Let’s call the table ‘associated_versions‘. It will have the columns – “parent_version_id“, “parent_type“, “child_version_id” and “child_type“.

Also, there should be a way to enable/disable the associated versioning for a model by just adding a line of code in the model classes. To achieve this an array containing the names of children classes for which we want to activate associated versioning is added to the parent class and vice versa for the child class. Hence, our classes are updated as follows :

class Company < ActiveRecord::Base
  has_paper_trail :class_name => 'CustomVersion'
  ASSOCIATED_CHILD_VERSIONS = ["Employee"]
end

class Employee < ActiveRecord::Base
  has_paper_trail :class_name => 'CustomVersion'
  ASSOCIATED_PARENT_VERSIONS = ["Company"]
end

These arrays will allow us to identify whether associated versioning is enabled for the model and for which associations.

We are all done with the setup and now only a callback method needs to be written which will be called each time a new version object gets inserted in the versions table.

Implementation of the create_associated_versions callback method
————————————————————————————————

The create_associated_versions callback method will be called for all new version objects. This version object can be of a parent class or a child class or both. Thus our first job is to determine which hierarchy this version falls in i.e. whether it’s a parent or a child or both. For this purpose, those arrays (ASSOCIATED_CHILD_VERSIONS, ASSOCIATED_PARENT_VERSIONS) that we previously added to the models will come handy. Making use of these arrays a method to determine hierarchy of a version can be written in the following way :

def version_heirarchy
  hierarchies = []
  hierarchies << "parent" if (defined?(item_type.constantize::ASSOCIATED_CHILD_VERSIONS)).present?
  hierarchies << "child" if (defined?(item_type.constantize::ASSOCIATED_PARENT_VERSIONS)).present?
  return hierarchies
end

Depending on the hierarchy of the version, our callback method create_associated_versions gets divided into different flows. One for 'parent' and one for 'child'. Both the flows are very similar, one major difference being that during parent's callback we create new associated_versions records while for child's callback we simply update the associated_versions record for that child.

Here is a code snippet for the parent's callback. get_version_ids_for is a helper method which fetches version ids for parent or child depending on the parameters.

if version_heirarchy.include? "parent"
  parent_class = self.item_type.constantize
  child_associations = parent_class::ASSOCIATED_CHILD_VERSIONS
  insert_query = "INSERT INTO associated_versions (parent_version_id, parent_type,   child_version_id, child_type) VALUES "
  child_associations.each do |child|
    child_version_ids = get_version_ids_for("child", item_id, item_type, child)
    next if child_version_ids.blank?
    child_version_ids.each do |child_version_id|
      if insert_query != "INSERT INTO associated_versions (parent_version_id, parent_type, child_version_id, child_type) VALUES "
        insert_query = "#{insert_query} , (#{self.id}, '#{item_type}', '#{child_version_id}', '#{child}')"
      else
        insert_query = "#{insert_query} (#{self.id}, '#{item_type}', '#{child_version_id}', '#{child}')"
      end
    end
  end
  connection.execute(insert_query) if insert_query != "INSERT INTO associated_versions (parent_version_id, parent_type, child_version_id, child_type) VALUES "
end

Accessor Methods
—————————

Now that we have set up the mechanism to store the associated versions data, it's time to retrieve it using accessor methods. This can be simply achieved by joining the versions table with the newly created associated_versions table. Therefore, code to retrieve associated child versions would look like :

def get_associated_child_versions(child_type)
  CustomVersion.find_by_sql("select versions.* from versions inner join associated_versions on versions.id = associated_versions.child_version_id where associated_versions.parent_version_id = #{self.id} and associated_versions.child_type = '#{child_type}' ")
end

To use this method in our code we just need to call this on an instance of CustomVersion, as below :

company = Company.find 42
ver = company.versions.last
child_versions = ver.get_associated_child_versions('Employee')

That's all folks. Hope you enjoyed the post and learned something new !

If you like to hack things in the Ruby ecosystem and work on cutting edge technologies, send across your profile to careers@traveltriangle.com

Comments