PaperTrail Gem Tutorial

Posted on April 20, 2020 - 22 Minute Read

Introduction

In this tutorial I am going to show you how to revert and restore records using the PaperTrail Gem.

Reverting Old Versions

reverting old versions

Restoring Deleted Versions

restoring old versions

Step 1: Setup

Generate Base Application

First we need to create a base application on which to build. Open up a terminal window and run the following commands.

rails new paper-trail-gem-tutorial -d=postgresql
cd paper-trail-gem-tutorial
rails db:create
rails g scaffold Article title body:text
rails db:migrate

Install and Configure PaperTrail

Next we need to install the paper_trail Gem. Open up your application's Gemfile and add the following.

# Gemfile
gem 'paper_trail', '~> 10.3', '>= 10.3.1'

Next, run the following commands from your application's root per the installation instructions.

bundle install
bundle exec rails generate paper_trail:install
bundle exec rake db:migrate

Next, add has_paper_trail to the Article model.

# app/models/article.rb
class Article < ApplicationRecord
  has_paper_trailend

Add Seed Data

In order to have something to work with, we'll want to add some seed data to our application.

faker is a library for generating fake data such as names, addresses, and phone numbers.

Add the faker Gem to your application's Gemfile and run bundle install.

# Gemfile
gem 'paper_trail', '~> 10.3', '>= 10.3.1'
gem 'faker', '~> 2.11'

Next, add the following to db/seeds.rb.

# db/seeds.rb
@article = Article.create(title: "Version 1", body: Faker::Lorem.paragraph)
2.upto(6) do |i|
  @article = Article.update(title: "Version #{i}")
end

1.upto(2) do |i|
  @deleted_article = Article.create(title: "Deleted Article #{i} Version 1", body: Faker::Lorem.paragraph)
  @deleted_article.destroy
  @deleted_article = Article.new(id: @deleted_article.id).versions.last.reify
  @deleted_article.save
  @deleted_article.update(title: "Deleted Article #{i} Version 2")
  @deleted_article.destroy
end

@restored_article = Article.create(title: "A Previously Deleted Article", body: Faker::Lorem.paragraph)
@restored_article.destroy
@restored_article = Article.new(id: @restored_article.id).versions.last.reify
@restored_article.save

Finally, run rails db:seed.

Update Root Path

Now we just need to update our routes so that the root_path displays our data.

Open up config/routes.rb and add the following:

# config/routes.rb
Rails.application.routes.draw do
  root to: "articles#index"  resources :articles
end

Finally, run rails s in your application's root directory and navigate to http://localhost:3000/. You should see something similar to the following:

homepage displaying articles

Step 2: Display Previous Versions

Now that we have a basic application with seed data, we can start to carve out our versioning system. The first step is to create a partial to be shared across layouts.

Create a Partial

First create a new partial by running touch app/views/articles/_article.html.erb in your application's root. Then, add the following:

<!-- app/views/articles/_article.html.erb -->
<tr>
  <td><%= article.title %></td>
  <td><%= article.body %></td>
  <td><%= link_to 'Show', article_path(article) %></td>
  <td><%= link_to 'Edit', edit_article_path(article) %></td>
  <td><%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %></td>
</tr>

Then, replace everything within <tbody></tbody> with <%= render @articles %> in app/views/articles/index.html.erb.

<!-- app/views/articles/index.html.erb -->
<p id="notice"><%= notice %></p>

<h1>Articles</h1>

<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Body</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <%= render @articles %>  </tbody>
</table>

<br>

<%= link_to 'New Article', new_article_path %>

Create a Versions Action

Next, open app/controllers/articles_controller.rb and add the following:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy, :versions]  ...
  def versions    @articles = @article.versions  end  ...
  private
end
  • We add :versions to the before_action :set_article so that our versions action has access to the @article stored in the private set_article method.
  • The .versions method is provided by paper_trail, and returns all versions of a given record.

Create a Versions Route

Next, open up config/routes.rb and add the following:

# config/routes.rb
Rails.application.routes.draw do
  root to: "articles#index"
  resources :articles do
    member do      get "versions", to: "articles#versions"    end  end
end
  • We use a member route in order to organize our route based on the associated Article.

Create a Versions View

Next, we'll need to create a corresponding view to display all Article versions. In the root of your application, run cp app/views/articles/index.html.erb app/views/articles/versions.html.erb

Next, open app/views/articles/versions.html.erb and add the following:

<!-- app/views/articles/versions.html.erb -->
<p id="notice"><%= notice %></p>

<h1>Previous Versions</h1>
<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Body</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <%= render partial: "article", collection: @articles %>  </tbody>
</table>

<br>

<%= link_to 'Back', articles_path %>
  • We update the title.
  • We add <%= render partial: "article", collection: @articles %> which will render the app/views/articles/_article.html.erb partial, and use the @articles instance variable from the versions action.

Refactor Article Partial

If you navigate to the new route, such as http://localhost:3000/articles/1/versions, you will see the following error:

undefined method `title' for #<PaperTrail::Version:0x00007f81a84c9fd8>

This is because the versions action is returning PaperTrail::Version instances, not Article instances.

Load Correct Version Data

In order to fix the error add the following to app/views/articles/_article.html.erb.

<!-- app/views/articles/_article.html.erb -->
<% unless article.try(:event) && article.event == "create" %>  <tr>
    <td><%= article.try(:reify) ? article.reify.title : article.title %></td>    <td><%= article.try(:reify)  ? article.reify.body : article.body %></td>    <td><%= link_to 'Show', article_path(article) %></td>
    <td><%= link_to 'Edit', edit_article_path(article) %></td>
    <td><%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %></td>
  </tr>
<% end %>

PaperTrail does not waste space storing a version of the object as it currently stands

  • We wrap our partial in a conditional that checks to see if the current version is simply the creation of the record by checking the event value.

    • Note that we we also run article.try(:event) incase the record being passed into the partial is not a PaperTrail::Version instance.
  • We conditionally load the title and body using a call to reify. Reify simply deserializes the value stored in the object column on a PaperTrail::Version instance. This value is a serialized version of the Article.

    • Note that we we also run article.try incase the record being passed into the partial is not a PaperTrail::Version instance.

This seems to have fixed the problem. However, if you visit the versions path for an article you will see any version where the action was destroy. For example, if you visit http://localhost:3000/articles/4/versions you should see the following:

a deleted version appearing in the version history

Open up app/views/articles/_article.html.erb and make the following edit:

<!-- app/views/articles/_article.html.erb -->
<% unless article.try(:event) && (article.event == "create" || article.event == "destroy") %>  <tr>
  ...
  </tr>
<% end %>
  • We add article.event == "destroy" to hide any version where the event was destroy.

Now if you visit http://localhost:3000/articles/4/versions you'll no longer see that deleted version.

Finally, let's add a link to the versions page for each Article. Open up app/views/articles/_article.html.erb and make the following edit:

<!-- app/views/articles/_article.html.erb -->
<% unless article.try(:event) && article.event == "create" %>
  <tr>
    <td><%= article.try(:reify) ? article.reify.title : article.title %></td>
    <td><%= article.try(:reify)  ? article.reify.body : article.body %></td>
    <% if params[:action] == "index" %>      <td><%= link_to 'Show', article_path(article) %></td>
      <td><%= link_to 'Edit', edit_article_path(article) %></td>
      <td><%= link_to 'Versions', versions_article_path(article) %></td>      <td><%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %></td>
    <% end %>  </tr>
<% end %>
  • We wrap our links in a conditional so that they only appear when viewed on the index view.

If you navigate to http://localhost:3000/articles/1/versions you should see the following:

previous versions

Step 3: Preview Previous Versions

Now that we have a page which lists all previous versions, we'll want to add a page to preview that version.

Create a Version Action

Open up app/controllers/articles_controller.rb and add the following:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy, :versions, :version]  before_action :set_version, only: [:version]  ...
  def version  end  ...
  private
    ...
    def set_version      @version = PaperTrail::Version.find_by(item_id: @article, id: params[:version_id])    end    ...
end
  • We create a private set_version method that finds a particular version of an Article.
  • The PaperTrail::Version instance can be queried just like any other record.

    • Here, we're looking for an instance of PaperTrail::Version where the item_id is the same as the id of current @article, and the id is set from params[:version_id].

Create a Version Route

Next, open up config/routes.rb and add the following:

# config/routes.rb
Rails.application.routes.draw do
  root to: "articles#index"
  resources :articles do
    member do
      get "versions", to: "articles#versions"
      get "version/:version_id", to: "articles#version", as: "version"    end
  end
end

Create a Version View

Finally, create a new view by running cp app/views/articles/show.html.erb app/views/articles/version.html.erb in the applications root.

Open up app/views/articles/version.html.erb and make the following edits:

<!-- app/views/articles/version.html.erb -->
<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @version.reify.title %></p>

<p>
  <strong>Body:</strong>
  <%= @version.reify.body %></p>

<%= link_to 'Back', versions_article_path(@article) %>
  • We call reify in order to deserialize the value stored in the @version.object column.
  • We add a back button for improved user experience.

Refactor Article Partial

Now that we have a view to render a preview, we can add a link allowing a user to view that specific version.

Open up app/views/articles/_article.html.erb and add the following:

<!-- app/views/articles/_article.html.erb -->
<% unless article.try(:event) && article.event == "create" %>
  <tr>
    <td><%= article.try(:reify) ? article.reify.title : article.title %></td>
    <td><%= article.try(:reify)  ? article.reify.body : article.body %></td>
    <% if params[:action] == "index" %>
      <td><%= link_to 'Show', article_path(article) %></td>
      <td><%= link_to 'Edit', edit_article_path(article) %></td>
      <td><%= link_to 'Versions', versions_article_path(article) %></td>
      <td><%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %></td>
    <% end %>
    <% if params[:action] == "versions" %>        <td><%= link_to 'Preview This Version', version_article_path(@article, article) %></td>    <% end %>  </tr>
<% end %>
  • Note the we conditionally load this link to only appear on the versions view.

If you navigate to http://localhost:3000/articles/1/versions you should see the following:

preview this version links

If you navigate to a specific version, like http://localhost:3000/articles/1/version/2, you should see a preview of that version.

version show page

Step 4: Revert Previous Versions

Now that we can view previous versions, we need the ability to revert back to them.

Create Revert Action

Open up app/controllers/articles_controller.rb and add the following:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :set_article, only: [:show, :edit, :update, :destroy, :versions, :version, :revert]  before_action :set_version, only: [:version, :revert]  ...
  def revert    @reverted_article = @version.reify    if @reverted_article.save      redirect_to @article, notice: "Article was successfully reverted."    else      render version    end  end
  ...
  private
end
  • We add :revert to before_action :set_article and before_action :set_version in order to have access to the specific @article and @version.
  • We call reify on the @version in order to deserialize the data stored in the object column.

Create Revert Route

Next we need to create a route to correspond with this action. Open up config/routes.rb and add the following:

# config/routes.rb
Rails.application.routes.draw do
  root to: "articles#index"
  resources :articles do
    member do
      get "versions", to: "articles#versions"
      get "version/:version_id", to: "articles#version", as: "version"
      post "revert/:version_id", to: "articles#revert", as: "revert"    end
  end
end
  • Note that we issue a post request, since we're writing to the database.

Refactor Version Partial

Now that we have an action and corresponding route, we need the ability to revert via a link. Open up app/views/articles/version.html.erb and add the following:

<!-- app/views/articles/version.html.erb -->
<p id="notice"><%= notice %></p>

<p>
  <strong>Title:</strong>
  <%= @version.reify.title %>
</p>

<p>
  <strong>Body:</strong>
  <%= @version.reify.body %>
</p>

<%= link_to 'Revert to this version', revert_article_path(@article, @version), method: :post %><%= link_to 'Back', versions_article_path(@article) %>
  • Note that we add method: :post to issue a post request.

If you navigate to http://localhost:3000/articles/1/version/2 for should now be able to revert to older versions.

reverting to previous version

Step 5: Display Deleted Versions

Now let's add the ability to view deleted versions.

Create Deleted Action

Open up app/controllers/articles_controller.rb and add the following:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  ...
  def deleted    @articles = PaperTrail::Version.where(item_type: "Article", event: "destroy").order(created_at: :desc)  end  ...
  private
  ...
end
  • Here we simply query for all instances of PaperTrail::Version where the item_type is Article and the event was destroy. Simply put, this finds all versions of deleted Articles.
  • We order the query by created_at: :desc, in order to view the most recently deleted Articles first.

Create Deleted Route

Next we need to add a corresponding route for our deleted action. Open up config/routes.rb and add the following:

# config/routes.rb
Rails.application.routes.draw do
  root to: "articles#index"
  resources :articles do
    member do
      get "versions", to: "articles#versions"
      get "version/:version_id", to: "articles#version", as: "version"
      post "revert/:version_id", to: "articles#revert", as: "revert"
    end
    collection do      get "deleted", to: "articles#deleted"    end  end
end
  • We use a collection route in order for the path to be articles/deleted.

Create Deleted View

Now we need a view to display deleted Articles. In the root of your application run cp app/views/articles/versions.html.erb app/views/articles/deleted.html.erb

Now, open up app/views/articles/deleted.html.erb and add the following:

<!-- app/views/articles/deleted.html.erb -->
<p id="notice"><%= notice %></p>

<h1>Deleted Articles</h1>
<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Body</th>
      <th colspan="3"></th>
    </tr>
  </thead>

  <tbody>
    <%= render partial: "article", collection: @articles %>
  </tbody>
</table>

<br>

<%= link_to 'Back', articles_path %>
  • Note that all we change is the page tilte.

Refactor Deleted Action

If you navigate to http://localhost:3000/articles/deleted you should see the following:

a page listing every version where the action was deleted

What's happening is that we're seeing ALL versions of deleted Articles. For example, an Article can be destroyed, and then restored, and then destroyed again many times. This means that there are can be multiple instances of versions where the event is set to destroy for a single Article.

To account for this, we need to refactor our deleted action. Open up app/controllers/articles_controller.rb and update the following:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  ...
  def deleted
    @destroyed_versions = PaperTrail::Version.where(item_type: "Article", event: "destroy").order(created_at: :desc)    @latest_destroyed_versions = @destroyed_versions.filter { |v| v.reify.versions.last.event == "destroy" }.map(&:reify).uniq(&:id)    @articles = @latest_destroyed_versions  end
  ...
  private
  ...
end

Understanding the Query

Admittedly this refactor is a little cryptic, so let's go through it in smaller pieces.

Our end goal is to get the latest deleted version of an Article, and ignore older deleted versions of the same Article.

Open up the rails console and by running rails c -s

PaperTrail::Version.where(item_type: "Article", event: "destroy")
> PaperTrail::Version.where(item_type: "Article", event: "destroy").order(created_at: :desc).count
 ...
 => 5
  • Here we simply query for all instances of PaperTrail::Version where the item_type is Article and the event was destroy. Simply put, this finds all versions of deleted Articles.
.filter { |v| v.reify.versions.last.event == "destroy" }
> PaperTrail::Version.where(item_type: "Article", event: "destroy").order(created_at: :desc).filter { |v| v.reify.versions.last.event == "destroy" }.count
 ...
 => 4
  • Building off of that query, we can call filter to only return Articles where the last version was destroy. This is important because we don't need to see all previous version of the Article when the event was destroy.
  • This goes back to the idea that an Article can be destroyed, and then restored multiple times. If an Article was destroyed, and then restored, we no longer need to see the version of the Article when is was destroyed.
.map(&:reify)
> PaperTrail::Version.where(item_type: "Article", event: "destroy").order(created_at: :desc).filter { |v| v.reify.versions.last.event == "destroy" }.map(&:reify)
  ...
 => [#<Article id: 3, title: "Deleted Article 2 Version 2", body: "Est aut ex. Ea sit ipsam. Tempora dolorem fuga.", created_at: "2020-04-20 14:34:39", updated_at: "2020-04-20 14:34:39">, #<Article id: 3, title: "Deleted Article 2 Version 1", body: "Est aut ex. Ea sit ipsam. Tempora dolorem fuga.", created_at: "2020-04-20 14:34:39", updated_at: "2020-04-20 14:34:39">, #<Article id: 2, title: "Deleted Article 1 Version 2", body: "Quaerat praesentium sint. Repudiandae explicabo no...", created_at: "2020-04-20 14:34:39", updated_at: "2020-04-20 14:34:39">, #<Article id: 2, title: "Deleted Article 1 Version 1", body: "Quaerat praesentium sint. Repudiandae explicabo no...", created_at: "2020-04-20 14:34:39", updated_at: "2020-04-20 14:34:39">]
  • By addng a call to map, we can return an array of Article instances, and not PaperTrail::Version instances.
  • This will allow us to have access to columns on the Article class.
.uniq(&:id)
> PaperTrail::Version.where(item_type: "Article", event: "destroy").order(created_at: :desc).filter { |v| v.reify.versions.last.event == "destroy" }.map(&:reify).uniq(&:id)
...
 => [#<Article id: 3, title: "Deleted Article 2 Version 2", body: "Est aut ex. Ea sit ipsam. Tempora dolorem fuga.", created_at: "2020-04-20 14:34:39", updated_at: "2020-04-20 14:34:39">, #<Article id: 2, title: "Deleted Article 1 Version 2", body: "Quaerat praesentium sint. Repudiandae explicabo no...", created_at: "2020-04-20 14:34:39", updated_at: "2020-04-20 14:34:39">]
  • Finally, we can add a call to uniq to eliminate duplicates from the array of Article instances based on the id column.
  • It's important that we called .order(created_at: :desc) on the query. Otherwise the new array of Article instances would be older deleted versions.

Now if you navigate to http://localhost:3000/articles/deleted, you should see a filtered list.

only the most recently deleted versions appear

Step 6: Restore Deleted Versions

Now that the heavy lifting is done, we can easily restore a deleted Article.

Create Restore Action

Open up app/controllers/articles_controller.rb and add the following:

# app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  ...
  def restore    @latest_version = Article.new(id: params[:id]).versions.last    if @latest_version.event == "destroy"      @article = @latest_version.reify      if @article.save        redirect_to @article, notice: "Article was successfully restored."      else        render "deleted"      end    end  end  ...
  private
  ...
end
  • We make sure to see if the latest version was triggered by a destroy event. This is important because we don't want to restore a version that is actually no longer destroyed.

Create Restore Route

Next, we need to add a corresponding route for our restore action. Open up config/routes.rb and add the following:

# config/routes.rb
Rails.application.routes.draw do
  root to: "articles#index"
  resources :articles do
    member do
      get "versions", to: "articles#versions"
      get "version/:version_id", to: "articles#version", as: "version"
      post "revert/:version_id", to: "articles#revert", as: "revert"
      post "restore", to: "articles#restore", as: "restore"    end
    collection do
      get "deleted", to: "articles#deleted"
    end
  end
end
  • Note that we issue a post request, since we're writing to the database.

Refactor Article Partial

Now that we have an action and corresponding route, we need the ability to restore via a link. Open up app/views/articles/version.html.erb and add the following:

<!-- app/views/articles/_article.html.erb -->
<% unless article.try(:event) && article.event == "create" %>
  <tr>
    <td><%= article.try(:reify) ? article.reify.title : article.title %></td>
    <td><%= article.try(:reify)  ? article.reify.body : article.body %></td>
    <% if params[:action] == "index" %>
      <td><%= link_to 'Show', article_path(article) %></td>
      <td><%= link_to 'Edit', edit_article_path(article) %></td>
      <td><%= link_to 'Versions', versions_article_path(article) %></td>
      <td><%= link_to 'Destroy', article_path(article), method: :delete, data: { confirm: 'Are you sure?' } %></td>
    <% end %>
    <% if params[:action] == "versions" %>
        <td><%= link_to 'Preview This Version', version_article_path(@article, article) %></td>
    <% end %>
    <% if params[:action] == "deleted" %>        <td><%= link_to 'Restore This Article', restore_article_path(article), method: :post %></td>    <% end %>  </tr>
<% end %>
  • Note the we conditionally load this link to only appear on the deleted view.

If you navigate to http://localhost:3000/articles/deleted you should be able to restore a deleted Article.

restoring a deleted article

Conclusion and Next Steps

The PaperTrail Gem is powerful in it's simplicity. By storing all events and their associated record in a separate versions table, one can create an auditing version as simple or as complex as needed. This tutorial just explored common patterns. Don't feel you need to follow them precisely.

As always, make sure to thoroughly test your application. You can view this application's controller and integration tests for inspiration.

Categorized In: