Back to Tutorials
gems|Jutsu6/8/2025

Version your models with PaperTrail (Rails 4)

Version your models with PaperTrail (Rails 4)

Today we're going to talk about versioning. How to version your models to be more specific.

Versioning my models ? What is that ?

It's simple. For example, if you have a document that many people can edit, like a Google Doc, you better keep the previous versions of the file. Just in case someone does some s*** on it. That's exactly what we're going to do in this Jutsu ! Let's get started.

The project

The web app we will create is a simplified version of Google Docs. Very, very simplified. You will have a list of files that anyone can edit, based on a very simple authentication system. You will be able to see all the previous versions of the file, and who changed it. You will also be able to rollback to a previous version of your choice. To do all this, we're going to use the PaperTrail gem.

Setup the app

In this guide, we won't build the app from scratch like usually. Instead, you can pull the code from Github.

git clone https://github.com/T-Dnzt/papertrail-jutsu-initial.git ./papertrail-jutsu && cd papertrail-jutsu

Run the migration, bundle and start the app :

rake db:migrate
bundle install
rails server

If you head over to localhost:3000, you should see the following :

A screenshot of the app built for Jutsu #8. Show a form to signup and login.

You can enter your name and access the app. Once logged in, you should be able to manage documents.

Show the list of documents.

PaperTrail

Let's add PaperTrail which will automatically keep track of what happened to our documents.

# Gemfile
gem 'paper_trail'

Now, 3 steps in one line to get it to work :

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

Restart your server before continuing.

After that, we can add PaperTrail to the model we want to version :

# app/models/document.rb
class Document < ActiveRecord::Base
  belongs_to :user

  has_paper_trail

  def user_name
    user ? user.name : ''
  end

end

And that's it ! Now everytime we save our model, we'll get the previous version saved by PaperTrail :

PaperTrail::Version.all

# => #<ActiveRecord::Relation [#<PaperTrail::Version id: 1, item_type: "Document", item_id: 1, event: "update", whodunnit: "1", object: "---\nid: 1\nname: Abcz\ncontent: aaa\nuser_id: 1\ncreat...", created_at: "2014-09-26 15:38:14">]>

Listing the previous versions

Now, let's see what we can do with that! First, we're going to add the list of versions for a specific document. We're going to need a way to easily get the name of someone who authored a version. For that, we need a helper method and a method in the User model :

# app/helpers/documents_helper.rb
module DocumentsHelper
  def find_version_author_name(version)
    user = User.find_version_author(version)
    user ? user.name : ''
  end
end

# app/models/user.rb
class User < ActiveRecord::Base
  has_many :documents
  def self.find_version_author(version)
    find(version.terminator)
  end
end

Now, let's add the actual list of versions to the edit document view as a partial :

# app/views/documents/_versions.html.erb
<h2>Previous Versions</h2>

<table class='table'>
  <thead>
    <tr>
      <th>Index</th>
      <th>Date</th>
      <th>Author</th>
      <th>Event</th>
      <th></th>
      <th></th>
      <th></th>
    </tr>
  </thead>

  <tbody>
    <%- document.versions.reverse.each do |version| %>
      <tr>
        <td><%= version.index %></td>
        <td><%= version.created_at %></td>
        <td><%= find_version_author_name(version) %></td>
        <td><%= version.event.humanize %></td>
        <td><%= link_to 'Diff', '' %></td>
        <td><%= link_to 'Rollback', '' %></td>
      </tr>
    <% end %>
  </tbody>
</table>

And render this partial after the Document form :

# app/views/documents/edit.html.erb
...
<%= render 'form' %>
<%= render 'documents/versions', document: @document %>

Now you can udpate any document a few times (create one if you don't have any yet).

You should see the list of versions growing !

The list of versions for a specific document.

Add Diff & Rollback

So, you've probably noticed the two empty links named 'diff' and 'rollback'. You probably already know what we're going to do with those. Clicking on diff will show us a new page with the difference between the clicked version and the current version. Rollback will simply change the current object to be like the clicked version. Let's go!

First, we're going to update the edit document view to add action to the links :

# app/views/documents/_versions.html.erb
<td><%= link_to 'Diff', diff_document_version_path(document, version) %></td>
<td><%= link_to 'Rollback', rollback_document_version_path(document, version), method: 'PATCH' %></td>

Then we need the routes :

# config/routes.rb
resources :documents do
  resources :versions, only: [:destroy] do
    member do
      get :diff, to: 'versions#diff'
      patch :rollback, to: 'versions#rollback'
    end
  end
end

And finally, we create the controller :

# app/controllers/versions_controller.rb
class VersionsController < ApplicationController
  before_action :require_user
  before_action :set_document_and_version, only: [:diff, :rollback, :destroy]

  def diff
  end

  def rollback
    # change the current document to the specified version
    # reify gives you the object of this version
    document = @version.reify
    document.save
    redirect_to edit_document_path(document)
  end

  private

    def set_document_and_version
      @document = Document.find(params[:document_id])
      @version = @document.versions.find(params[:id])
    end

end

If you try to diff a version, you will get a missing template exception. However, you can already rollback to a previous version! Pretty cool, huh!

All we need for the diff to work now, is a view. We could build one by ourselves, but let's not reinvent the wheel. We're going to use the very nice gem Diffy. Diffy gives us an easy way to diff content (files or strings) in Ruby by using Unix diff.

Diff with Diffy

Add the gem to your Gemfile :

 # Gemfile
 ...
 gem 'paper_trail'
 gem 'diffy'
 ...

bundle install and restart your server.

Since we will be using the same diff logic for the name and content of a document, let's create a helper method :

# app/helpers/documents_helper.rb
def diff(content1, content2)
   changes = Diffy::Diff.new(content1, content2,
                             include_plus_and_minus_in_html: true,
                             include_diff_info: true)
   changes.to_s.present? ? changes.to_s(:html).html_safe : 'No Changes'
end

Basically, we just tell Diffy that we want to generate html if there is any difference between the 2 strings. We also want to have the diff info (number of lines changed, etc) and the + and - that everbody likes!

Now the actual diff view is pretty simple to build. We're going to create it in app/views/versions/ :

# app/views/versions/diff.html.erb
<div class='row mt'>
  <div class='col-sm-12'>
    <h2><%= "Diff between Version #{@version.id} and Current Version" %></h2>
    <style><%= Diffy::CSS %></style>
    <div class='well diff'>
      <p>
        <strong>Name:</strong>
        <%= diff(@version.reify.name, @document.name) %>
      </p>
      <p>
        <strong>Content:</strong>
        <%= diff(@version.reify.content, @document.content) %>
      </p>
    </div>
    <p>
    <%= "Version authored by #{find_version_author_name(@version)} on #{@version.created_at} by '#{@version.event.humanize}'." %>
    </p>
  </div>
</div>
<div class='fr'>
  <%= link_to 'Back', edit_document_path(@document), class: 'btn btn-danger' %>
  <%= link_to 'Rollback', rollback_document_version_path(@document, @version), class: 'btn btn-primary', method: 'PATCH' %>
</div>

If you try it, you should see something like that :

Showing the diff page with different content between 2 versions.

Very nice! Now, our app is missing one very important feature. A way to bring back documents from the graveyard! Who never deleted something by accident ? But before we do that, I want to show you how you can use your own version classes.

Custom Version Class

Why would we want to create custom classes to handle the versions of our models ? Well, with this approach, you can have a version model and table per model you want to version. Since you're using different tables for each of your model, you won't have one huge table that contains versions for this or that. Plus, you can add some specific field to a specific Version model by using metadata as we'll see soon.

Adding a custom version class is actually quite easy. First, we need to generate a migration :

rails g migration create_document_versions

You can paste this in it :

class CreateDocumentVersions < ActiveRecord::Migration
  def change
    create_table :document_versions do |t|
      t.string   :item_type, :null => false
      t.integer  :item_id,   :null => false
      t.string   :event,     :null => false
      t.string   :whodunnit
      t.text     :object
      t.datetime :created_at
      t.string   :author_username
      t.integer  :word_count
    end
    add_index :document_versions, [:item_type, :item_id]
  end
end

As you've probably noticed, we added 2 new columns : author_username and word_count. We'll use those to add some metadata to our versions later in this guide. Migrate and we can continue.

The corresponding model :

# app/models/document_version.rb
class DocumentVersion < PaperTrail::Version
  self.table_name = :document_versions
  default_scope { where.not(event: 'create') }
end

Note the default_scope we added. We don't really care about create events since those don't contain anything. Let's just exclude them.

The last thing to do is tell the Document model to use our custom version model instead of the default one :

# app/models/document.rb
...
has_paper_trail class_name: 'DocumentVersion'
...

Try the app and see if everything is still working. Of course, you won't have the versions that you had before because we changed the table!

Bring back Documents

To be able to bring back documents, we need to show all the deleted documents. We're going to add a route to access all the deleted documents. Here is the whole route file. We are also adding a route to undelete.

Rails.application.routes.draw do
  resources :documents do
    collection do
      get :deleted # <= this
    end

    resources :versions, only: [:destroy] do
      member do
        get :diff, to: 'versions#diff'
        patch :rollback, to: 'versions#rollback'
      end
    end
  end

  resources :versions, only: [] do
    member do
      patch :bringback  # <= and that
    end
  end

  resources :sessions, only: [:new, :create] do
    delete 'logout', on: :collection
  end
  root to: 'documents#index'
end

The actions in our controllers :

# app/controllers/documents_controller.rb
...
# Get all the versions where the event was destroy
def deleted
  @documents = DocumentVersion.where(event: 'destroy')
end
...

and :

# app/controllers/versions_controller.rb
...
def bringback
  version = DocumentVersion.find(params[:id])
  @document = version.reify
  @document.save
  # Let's remove the version since the document is undeleted
  version.delete
  redirect_to root_path, notice: 'The document was successfully brought back!'
end
...

And a view to list the deleted documents :

# app/views/documents/deleted.html.erb
<h1>Bring back documents</h1>
<table class='table'>
  <thead>
    <tr>
      <th>ID</th>
      <th>Name</th>
      <th>Description</th>
      <th>Deleted by</th>
      <th>At</th>
      <th colspan="3"></th>
    </tr>
  </thead>
  <tbody>
    <% @documents.each do |document_version| %>
      <%- document = document_version.reify %>
      <tr>
        <td><%= document.id %></td>
        <td><%= document.name %></td>
        <td><%= document.content %></td>
        <td><%= find_version_author_name(document_version) %></td>
        <td><%= document.created_at %></td>
        <td><%= link_to 'Bringback', bringback_version_path(document_version), method: 'PATCH' %></td>
      </tr>
    <% end %>
  </tbody>
</table>
<br>
<%= link_to 'Back', documents_path %>

And finally, a link to access this view!

# app/views/documents/index.html.erb
...
<%= link_to 'See Deleted Documents', deleted_documents_path %>
<br/>
<%= link_to 'New Document', new_document_path %>

Now try it! Delete a document and it will appear in the list of deleted. You should be able to easily bring it back too. The app is pretty much done, but I'd like to share with you 2 more features of PaperTrail.

MetaData

PaperTrail gives us a way to save additional information in each version. In our case, we're going to save the author name (even if we can get it with the whodunnit id) and the number of word in the content.

Remember when we created the migration for you custom DocumentVersion model ? We're going to use those!

# app/models/document.rb
...
has_paper_trail class_name: 'DocumentVersion',
                meta: { author_username: :user_name, word_count: :count_word }
...

When PaperTrail generate a new version, it will call the defined methods (user_name) on document an save it in the specified field (author_username).

We need to add a method named count_word :

def count_word
  content.split(' ').count
end

And since we added all those information, we should show it in our list of versions.

# app/views/documents/_versions.html.erb
...
<th>Index</th>
<th>Date</th>
<th>Author</th>
<th>Event</th>
<th>Word Count</th>
...
<td><%= version.id %></td>
<td><%= version.created_at %></td>
<td><%= version.author_username %></td>
<td><%= version.event.humanize %></td>
<td><%= version.word_count %></td>

And save a few versions to see the metadata!

The little of versions with metadata.

Who needs 100 versions ?

The last trick I want to show you is a way to limit the number of versions you save. We probably don't need the 100 previous versions, 10 to 30 should be enough. You can define that in PaperTrail configuration :

# config/initializers/paper_trail.rb
PaperTrail.config.version_limit = 10

Et voila!

Want more ?

You can check more about PaperTrail on the official Readme. There is also a nice Railscast showing how to create an undo system.

Source Code

You can get the starting code here and the complete code there.

Warmup

That's it ! That was a long article and I hope you enjoyed it and learned a few things ;)

Comments

Loading comments...

Level Up Your Dev Skills & Income 💰💻

Learn how to sharpen your programming skills, monetize your expertise, and build a future-proof career — through freelancing, SaaS, digital products, or high-paying jobs.

Join 3,000+ developers learning how to earn more, improve their skills, and future-proof their careers.

Version your models with PaperTrail (Rails 4) | Devmystify