joestelmach.com

Model Versioning in Rails

Versioned data is common to many applications. Consider the case of an online store selling products. A simplified view of the product data might consist of the upc, description, volume, and price. Let's suppose a customer purchases a product on September 12th for a certain amount, then we update the product info to reflect a price increase. If we dont keep a snapshot of the product data before the price increase, the customer's order history will be generated from the new product data instead of the product data at the time of purchase.

Ok, so we basically need a parallel product table to append a new entry to each time a product's data is updated. The main product table will always hold the current data. In Rails, this means that we need to somehow hijack the ActiveRecord call flow and intercept any updates to all product instances, append a new entry to the versioned table with a timestamp, and then somehow tell active record to resume its updating of the original product, making note of the version that was just appended. That sucks. I thought about using an observer, or maybe a callback...but there is definitely a better way - the acts_as_versioned plugin.

Let's go through the basic setup to getting this working.

  1. Install the plugin:
    prompt$ gem install acts_as_versioned
  2. update the product model to use the plugin:
    class Product < ActiveRecord::Base
        acts_as_versioned
        ...
    end
  3. Create your database tables.
    Class CreateProducts < ActiveRecord::Migration
      def self.up
        create_table :products do |t|
          t.column :version, :integer
          t.column :upc, :integer
          t.column :description, :string, :null => false
          t.column :volume, :float, :null => false
          t.column :price, :decimal
        end
    
        Product.create_versioned_table
      end
    
      def self.down
        drop_table :products
        Product.drop_versioned_table
      end
    end

    Note that we are required here to add a version column to the main product table that will automatically be updated with the current version on each update (sequential, starting at 1.) You can use the version_column option to the acts_as_versioned declaration if you would like to rename the column. Also note that we use the create_versioned_table class method that was added to the Product class with the acts_as_versioned declaration. This method will automatically create a product_versions table with a product_id foreign key column, an updated_at datetime column, a version column, and a colum for each column in the products table. The drop_versioned_table class method will drop the table generated by the corresponding call to create_versioned_table.

Now you can start adding products and updating them, and you will see the versions magically appear in the product_versions table. There are a couple of problems here though. First of all, we are versioning all of the columns from the original model. In our example here, once a product is created, I know that the UPC cannot be changed, therefore there is no point in wasting a column for every version of every product. It turns out that the plugin will refer to the non_versioned_fields class method to determine which columns to generate in the migration and which columns in the versioned table (and corresponding object methods) to populate on each update. If we add our own columns to the array returned by non_versioned_fields, we can exclude certain columns from being versioned:

class Product < ActiveRecord::Base
  acts_as_versioned
  non_versioned_fields.push "upc"
end

Note here that you pushing symbols onto the array will not work, you must use strings. I have to admit that I'm a bit skeptical of the correctness of my implementation here, so please let me know if something seems a bit funky with the column exclusions (or anything else for that matter.)

The other problem is that by default, a new version will be appended to the versions table on every update. This means that if you change nothing on your product object and call update, a new version will be added. We can correct this using the :if_changed option to the original acts_as-versioned declaration.

class Product < ActiveRecord::Base
  acts_as_versioned :if_changed => [:description, :volume, :price]
  ...
end

This means that a new version will not be appended unless the description, volume, or price column has changed. The plugin also supports more complex version checks with :if and version_condition_met?.

I have to say that I'm pretty excited to start using this in some of my projects. At first I was a bit worried about the fact that the most current realease starts with a 0, but hey - its probably a hell of a lot more solid than anything I could come up with :)

Comments
blog comments powered by Disqus