How To Use Paper Trail As An Activity Feed

Jun 15, 2015 PaperTrail, Ruby on Rails

If you're here, I assume you already know about the PaperTrail gem. If not, check it out, it's a powerful way to track changes on records within a model.

PaperTrail does one thing (track changes on a model), and it does that well. It's good for seeing how a record changes over time, and it's even better for rolling back unwanted changes to a particular record.

There's a good Railscast on how you can use PaperTrail to undo changes you've made to a particular model.

Because PaperTrail is tracking changes on a model, you might think (as I did), Maybe I can use it as an activity log. Now, granted, an activity log is not that difficult to create from scratch, but if PaperTrail is already saving versions of a model, why not use those as a sort-of app activity history?

The Catch

Before we dive further into it, let me explain the overall gotcha! PaperTrail is what a good tool or application is – opinionated. It maintains its focus, and it does the one thing it was meant to do very well. As a result, to use as it is not (necessarily) intended means we have to be creative.

Accessing Versions

When you install PaperTrail, you get a (namespaced) Version model table. This is what PaperTrail accesses for version control, and if you use it as it was intended, you're typically using methods on your models to access these versions.

But since it is still a rails model, we can get there directly. Here's how we would get all versions:

PaperTrail::Version.all

Scoping

You can imagine how that will get really messy, really fast. So, first, let's limit our results.

PaperTrail::Version.all.limit(20)

What might be even better than limiting is to paginate. I like using Kaminari for that task.

Now we have only 20 versions, but we're not specifying the method by which we're ordering our query. Naturally, we'd want the most recent first.

PaperTrail::Version.order(:created_at => :desc).limit(20)

Displaying Results

We now have the most recent 20 versions of any model created within our application. The Version object has three useful attributes we're going to take advantage of:

Note that whodunnit is just a reference, not an actual association. This means you have to go find the user. In this case, we're going to assume a User model.

Furthermore, because we're displaying this through our UI, we want to ensure we actually have a whodunnit. So, first, maybe we change our query to this:

@versions = PaperTrail::Version.where('whodunnit IS NOT ?', nil)
  .order(:created_at => :desc).limit(20)

Our listing view probably just calls the magic render method:

<%= render @versions %>

This will render a version partial view for each version in the collection. That will go in app/views/paper_trail/versions.

app/views/paper_trail/versions/_version.html.erb

<% user = User.find_by_id(version.whodunnit) %>
<% unless user.nil? %>
  <div class="version">
    <%= user.name %> <%= version.event %>d <%= version.item_type.downcase %> <%= time_ago_in_words(version.created_at) %> ago.
  </div>
<% end %>

This is a very simple use case. Let's look at each item and our assumptions.

Finding Routes

Arguably, this isn't really useful unless you can jump to the object itself, right? This is where using PaperTrail for an activity log can become dicey.

If your routes aren't nested and if your routes are named for their model, then you can use interpolation and the send method to create dynamic route names. Using the view example above, your link to the item may look like this:

<%= link_to version.item.title, send("#{version.item_type.tableize}_path", version.item) %>

If the version were, say, a Post object, this would be the equivalent as:

<%= link_to version.item.title, post_path(version.item) %>

Solving for Eager Loading

If you're following along, you may have noticed how many database queries are being run. This describes an n+1 problem. This means the more items you have, the more queries you run. That's because every time you render a version using this method, you run version.item and User.find_..., which adds two queries. The Rails solution to this is called eager loading, and it means that we look to find all the records we need before we called them. That way we can access associations through memory and not by hitting the database again.

Item

To eager load the item, just add the includes method on your query, like so:

@versions = PaperTrail::Version.where('whodunnit IS NOT ?', nil)
  .order(:created_at => :desc).limit(20).includes(:item)

Try it again. You'll see how you (should) only hit the database once for every associated item.

User

Users are trickier. This is because whodunnit isn't actually an association. I still like to grab all the version users right away. Right after I query the versions, I might run this:

user_ids = @versions.collect(&:whodunnit).reject(&:blank?).map(&:to_i).uniq
@version_users = User.where(:id => user_ids)

The user_ids line collects all the whodunnits from the @versions collection and gives us a unique array of integers that we can use to query the User model.

Now, instead of finding a user like this:

<% user = User.find_by_id(version.whodunnit) %>

We can do this:

<% user = @version_users.select { |u| u.id == version.whodunnit.to_i }.first %>

It might look more complicated, but the point is that we're accessing the @version_users array, which is stored in memory, instead of hitting the database again.

When This Fails

This isn't a perfect activity log solution, and I've already run into several problems with it. Let's look at a few cases you may run into in more complex applications.

Nested Routes

If your routes are nested, meaning if you are tracking versions of multiple models and they are nested at different levels, then you will need if statements or case switches to render routes appropriately. That's usually easy enough, but it can get messy.

Scoping From An Object

I had to abandon this approach in one application in which I needed to scope the Version query within the context of another object. That's fine if you're limiting yourself to a specific item_type. So, if you only wanted posts and pages, you'd run:

@versions = PaperTrail::Version.where(:item_type => ['Post', 'Page'])
  .where('whodunnit IS NOT ?', nil).order(:created_at => :desc)
  .limit(20).includes(:item)

But let's say a page can have many posts. And let's say when we're on a page, we want to see the history of the posts for that page. This creates two problems.

First, you can't get to the item within the query. So you can't say give me all the version.item objects that are posts related to this page. In this case, you'd have to grab a heck of a lot more versions that you need and hope that you have the number you want. It will be an inefficient query in most cases.

And second, if you are grabbing more than one model and those models have different associations, you can't eager load them properly, and you'll run into another n+1 problem. And there really isn't a good, efficient, solution to this (at least not one I've thought of).

Alternatives

Well, my alternative is always to build my own solution, but there is a gem with some following around it called PublicActivity. And there's a Railscast on that, too.


References:

Did you learn something or find this article interesting?

If so, why not