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 :
)
You can enter your name and access the app. Once logged in, you should be able to manage 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 !
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 :
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!
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 ;)