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.
-
Install the plugin:
prompt$ gem install acts_as_versioned
-
update the product model to use the plugin:
class Product < ActiveRecord::Base acts_as_versioned ... end
-
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 theacts_as_versioned
declaration if you would like to rename the column. Also note that we use thecreate_versioned_table
class method that was added to the Product class with theacts_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. Thedrop_versioned_table
class method will drop the table generated by the corresponding call tocreate_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 :)