Quickly Create A Self Referential Versioning System For Ruby On Rails

22 February 2016 on . 4 minutes to read

self reference

The Need: Less Tables, More Functionality

For whatever reason, you find need to keep track of various versions of an object in your database. However, you don’t want the overhead and dependency of adding on a full featured auditing gem such as Paper Trail or Audited. In cases such authentication, authorization or security, rolling your own is not recommended for many reasons. The main one of which is that security is important, and the odds are greatly not in your favor that you’ll make something more secure than the industry has already produced. For something such as versioning though, a lite version such as I’ll instruct you how to make will be much better for a developer who needs a quick, simple solution.

Development: Stub It Out

I’m not a diehard TDD person who writes tests for every little thing, but adding custom features like versioning is the perfect place to insert some tests. For this example, you’re making widgets, and want to track changes to a specific widget. Any widget that has the same serial_number as another is simply a different version of the matching widget. Say one day you paint a widget blue, but want to track that an earlier version was red. Create a new widget with a matching serial number, but change the color, order them by created at timestamps, and BAM! You’ve got your versioning setup.

What should versions do?

  • Identify objects that are associated and of the same class
  • Objects should not include themselves in their versions

Adding on to the above, this will be a self referential relationship where a version won’t know that it is itself a version. Everyone thinks they’re the real thing but has easy access to their alter egos.

Build tests for the above

Almost all the unit tests I’ve written have been in RSpec. I haven’t ran into any limitations with it, so that’s what I stick with. Paired with Factory Girl courtesy of ThoughtBot and DatabaseCleaner tests are quick and concise. The following specs take the above requirements into consideration.

RSpec.describe Widget, type: :model do
  context "instance methods" do
    let(:widget) {create(:widget, serial_number: "1234")}
    it "#versions" do
        create(:widget, serial_number: "abc")
        create_list(:widget, 3, serial_number: "1234")
      }.to change{widget.versions.count}.by(3)
      expect(widget.versions.count).to eq 3

A quick note for anyone not familiar with FactoryGirl: create and create_list are both short for FactoryGirl.create and FactoryGirl.create_list, respectively. Above, we test that given a serial number of “1234”, adding 3 more widgets with a matching serial increases the amount of versions by 3, and also that in the end, there are ONLY 3 versions listed for a given widget with S/N “1234”. This ensures we don’t list the original one in its #versions call.

Code versions

My very first revision of this code was this:

has_many :versions, foreign_key: "serial_number", class_name: 'Widget'  

RSpec failed with: expected result to have changed by 3, but was changed by 0

Version 2:

  def versions
    Widget.where.not(id: id).where(serial_number: serial_number)

Functional, but this returns an array, rather than an Active Record object, which gives tons of extra handy methods which could be handy down the road. Building off the first version, I went through the logs and saw that #versions was creating a query that was searching for widget.id within the widget.serial_number field. I went back to a has_many :versions and added primary_key: "serial_number". That solved this problem, however, now RSpec gives the following: expected result to have changed by 3, but was changed by 4

Add a proc to ensure that nothing’s returned with the caller’s #id, and the tests go green. Very satisfying.

The following is the final version of versions, and all you need to use it in any of your own models! Just change serial_number to whatever field you’ll want to identify them by, and you’re good to.

has_many :versions, -> (widget) {where.not(id: widget.id)}, foreign_key: "serial_number", primary_key: "serial_number", class_name: 'Widget'  

KISS to the core.

If you enjoy having free time and the peace of mind that a professional is on your side, then you’d love to have me work on your project.

Contact or view a list of available services to see how I’ll make your life better, easier and bring satisfaction back into you running your business.