PaperTrail Gem Tutorial
Introduction
In this tutorial I am going to show you how to revert and restore records using the PaperTrail Gem.
Reverting Old Versions
Restoring Deleted 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_trail
end
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) { |i| @article = Article.update(title: "Version #{i}") }
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
# ℹ️ Add this route
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:
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 the article partial %>
<%= 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
# ℹ️ Add a before_action
before_action :set_article, only: %i[show edit update destroy versions]
# ℹ️ Add new controller action
def versions
@articles = @article.versions
end
end
- We add
:versions
to thebefore_action :set_article
so that ourversions
action has access to the@article
stored in the privateset_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
# ℹ️ Add route to versions action
member { get "versions", to: "articles#versions" }
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 theapp/views/articles/_article.html.erb
partial, and use the@articles
instance variable from theversions
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:
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 %>
<%# ℹ️ Only load these links if we are working on an existing article %>
<% unless article.try(:event) && article.event == "create" %>
<tr>
<%# ℹ️ Conditionally load the title for the previous version %>
<td><%= article.try(:reify) ? article.reify.title : article.title %></td>
<%# ℹ️ Conditionally load the body for the previous version %>
<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 aPaperTrail::Version
instance.
- Note that we we also run
- We conditionally load the
title
andbody
using a call to reify. Reify simply deserializes the value stored in theobject
column on aPaperTrail::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 aPaperTrail::Version
instance.
- Note that we we also run
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:
Open up app/views/articles/_article.html.erb
and make the following edit:
<%# app/views/articles/_article.html.erb %>
<%# ℹ️ Only load these links if we are editing existing article %>
<% unless article.try(:event) && (article.event == "create" || article.event == "destroy") %>
<tr>
...
</tr>
<% end %>
- We add
article.event == "destroy"
to hide any version where theevent
wasdestroy
.
Now if you visit http://localhost:3000/articles/4/versions
you’ll no longer see that deleted
version.
Add Versions Link to Partial
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>
<%# ℹ️ Only load these links on the index page %>
<% if params[:action] == "index" %>
<td><%= link_to 'Show', article_path(article) %></td>
<td><%= link_to 'Edit', edit_article_path(article) %></td>
<%# ℹ️ Add a link to the versions page %>
<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:
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
# ℹ️ Load the article on the version action
before_action :set_article,
only: %i[show edit update destroy versions version]
# ℹ️ Load the version on the version action
before_action :set_version, only: [:version]
def version
end
private
# ℹ️ Find the version based on the params
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 theitem_id
is the same as theid
of current@article
, and theid
is set fromparams[:version_id]
.
- Here, we’re looking for an instance of
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"
# ℹ️ Add a route for the version action
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>
<%# ℹ️ Load the title for the specific version %>
<%= @version.reify.title %>
</p>
<p>
<strong>Body:</strong>
<%# ℹ️ Load the body for the specific version %>
<%= @version.reify.body %>
</p>
<%# ℹ️ Link back to the other versions %>
<%= 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 %>
<%# ℹ️ Link to the version only if we're viewing a list of versions %>
<% 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:
If you navigate to a specific version, like http://localhost:3000/articles/1/version/2
, you should see a preview of that version.
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
# ℹ️ Add the revert action to both before actions
before_action :set_article,
only: %i[show edit update destroy versions version revert]
before_action :set_version, only: %i[version revert]
# ℹ️ Add a revert action
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
tobefore_action :set_article
andbefore_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 theobject
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"
# ℹ️ Add a route to the revert action
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>
<%# ℹ️ Add a link to revert to this version %>
<%= 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 apost
request.
If you navigate to http://localhost:3000/articles/1/version/2
for should now be able to revert to older versions.
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
# ℹ️ Add a deleted action to load any articles that were deleted
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 theitem_type
is Article and theevent
was destroy. Simply put, this finds all versions of deleted Articles. - We
order
the query bycreated_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
# ℹ️ Add a route to the new deleted action
collection { get "deleted", to: "articles#deleted" }
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:
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
# ℹ️ Get all deleted versions
@destroyed_versions =
PaperTrail::Version.where(item_type: "Article", event: "destroy").order(
created_at: :desc,
)
# ℹ️ Get the latest destroyed version of each article
@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 theitem_type
is Article and theevent
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 notPaperTrail::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 theid
column. - It’s important that we called
.order(created_at: :desc)
on the query. Otherwise the new array ofArticle
instances would be older deleted versions.
Now if you navigate to http://localhost:3000/articles/deleted
, you should see a filtered list.
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
# ℹ️ Get the last version of the article
@latest_version = Article.new(id: params[:id]).versions.last
# ℹ️ Restore the last version if it was destroyed
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"
# ℹ️ Add a route to the restore action
post "restore", to: "articles#restore", as: "restore"
end
collection { get "deleted", to: "articles#deleted" }
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 %>
<%# ℹ️ Load a link to restore the article if viewed from the deleted index %>
<% 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.
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.