Flexible Rails Modeling with ActiveRecord::Store – Part I

The current project I’m on needed a pretty flexible model structure that could support something that acted kind of like a Block or a Widget.

We wanted it to be easy to add a new type (which might have new fields and a new view), but not require the need for a new model, controller, full set of views etc. Additionally, we wanted all the widgets to sit in the same table so we could more easily present them to a user for selection, creation, and modification.

Initially, I thought Single Table Inheritance(STI) might be the answer (nice write up here, by Alex Reisner). But I quickly realized that STI would mean every time we want a new field for a new block type, we’d be running a migration.
And if that field is unused by the other block types, then we’re adding more logic in views and edit screens to manage this new field for this one block type.

Using separate models for each new block seemed similarly heavyweight. It would alleviate the logic for special fields based on block type. But it would mean lots of duplicate code as we add new model/controller/view code for each new type.

The method we came up with was to setup a model with a flexible field that is serialized and stored using ActiveRecord::Store. For any of you who’ve used the SpringFramework in Java, it has a similar feel. It allows us to flexibly add fields as necessary based on the block type and still share code for forms, controllers and views.

We start by setting up a base model (for this discussion, we’ll be building a Block). This model can be initialized with a minimal set of fields that you know will be universal for all Blocks. Let’s start with one field: title. We’ll also include a field to manage type: block_type. We don’t use type only because Rails has reserved that field name for STI models. We also add a text field called extensions. This is where we’ll stick all the custom fields.

# -- db/migrate/<date>_create_block.rb --
class CreateBlock < ActiveRecord::Migration
  def change
    create_table :blocks do |t|
      t.string :title
      t.string :block_type
      t.text :extensions
      t.timestamps
    end
  end
end

The model looks as you might expect. Using ActiveRecord::Store, we define the extensions field to be a ‘store’.

# -- app/models/block.rb --
class Block < ActiveRecord::Base
  RESERVED_FIELDS = [ :title, :block_type, :extensions ]
  attr_accessible *RESERVED_FIELDS

  store :extensions
end

You’ll see later why we’ve setup the RESERVED_FIELDS constant.

We’ll define our custom blocks using YAML. We can add a new block type by dropping a YAML in the config/blocks directory.
It might look something like this:

# -- config/blocks/rock_block.yml --
rock:
  display_name: Rock
  type: string
roll:
  display_name: Roll
  type: text

With this in place, we’ve just made our first custom block.

Now we tell the model to figure out what blocks it can handle and what their custom fields are. We add the following to the Block definition in models/block.rb.

  # determine possible block types by reading config files
  cattr_accessor :block_types

  @@block_types = {}

  def self.add_block_type key, block_data
    @@block_types[key] = HashWithIndifferentAccess.new(block_data)
    @@block_types[key].each do |k,v|
      unless RESERVED_FIELDS.include? k.to_sym
        store_accessor :extensions, k.to_sym
      end
    end
  end

  # initialize
  Dir.glob(File.join(Rails.root, 'config', 'blocks','*_block.yml')).each do |f|
    puts "Reading #{f}"
    begin
      key = File.basename(f).gsub(/\.yml$/,'').classify
      add_block_type key, YAML.load(File.open(f))
   rescue Exception => ex
      puts ex
      Rails.logger.warn "Failed to import block #{f}"
      Rails.logger.warn ex
    end
  end  

Now we can start constructing RockBlocks. Fire up a Rails console:

irb> block = Block.new(block_type: "RockBlock", title: "Sabbath")
=> #<Block id: nil, title: "Sabbath", block_type: "RockBlock", extensions: {}, created_at: nil, updated_at: nil>
irb> block.rock = "granite"
=> "granite"
irb> block.roll = "it's what wheels do"
=> "it's what wheels do"
irb> block.save
   (0.1ms)  BEGIN
  SQL (48.9ms)  INSERT INTO "blocks" ("block_type", "created_at", "extensions", "title", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id"  [["block_type", "RockBlock"], ["created_at", Fri, 23 Nov 2012 23:03:40 UTC +00:00], ["extensions", "---\n:rock: granite\n:roll: it's what wheels do\n"], ["title", "Sabbath"], ["updated_at", Fri, 23 Nov 2012 23:03:40 UTC +00:00]]
   (0.7ms)  COMMIT
=> true
irb> Block.last.roll
  Block Load (0.9ms)  SELECT "blocks".* FROM "blocks" ORDER BY "blocks"."id" DESC LIMIT 1
=> "it's what wheels do"

Note: going this route means that the custom fields will not be easily searchable since they’re stored in a serialized field. As long as that is not going to impact your application, then this way can make things very flexible.

To get controllers/views to work smoothly we did a little more magic which i wrote up in the next post.

[read more | check out the code]

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s