← back to all talks and articles

Working with Rails Attributes magic

The Rails framework comes with a lot of magic. Sometimes magic makes us productive, sometimes we need to work around it. One such area is how Rails is coupled to your database to provide some seemingly basic functionality.

ActiveRecord provides the handy store macro through ActiveRecord::Store that allows us to define a group of accessor methods that will be serialised together in a single text column in JSON, YAML or some other format. It’s handy for NoSQL-like flexible schema’s in your traditional relational database, or for quickly fleshing out a design you’re not too sure of yet.

Here’s how you define three attributes on an ActiveRecord model that will get serialised to a single text column in JSON format:

class Product < ActiveRecord::Base
  store :properties,
        accessors: %i(color size available_on),
        coder: JSON
end

Given you have a properties column in your products table, you can now use these like so:

product = Product.new
product.color # => nil
product.color = 'Red'
product.save
product.properties
# => "{ \"color\": \"Red\" }"

It’s kind of neat, but you might run into some unexpected problems. Here are a few I have run into.

Reading string-typed values

You cannot use Rails’ default date_select or datetime_select helpers in your forms, as our available_at does not return a DateTime object. You will most likely be greeted by an error like:

NoMethodError: undefined method 'day' for "2014-03-12":String

Luckily, this is easily solved with a custom reader method:

def available_at
  return super.to_datetime if super.respond_to?(:to_datetime)
  DateTime.parse(super)
end

This is not waterproof, but will suffice for most use cases to ensure we always get a DateTime object back.

Multi-parameter assignment errors

Second, try writing to our available_at property through a form. Rails has no knowledge that our virtual column should contain a DateTime object, so any value assigned to available_at will be assigned as-is (i.e. a string).

This will trigger a ActiveRecord::MultiparameterAssignmentErrors error, without too much hints on how to fix it. The thing is, Rails uses a special convention for its parameters’ names in order to create values through constructor methods that take multiple parameters.

Here’s what usually happens. Fist, when you use a datetime_select helper to pick “2014-04-21 12:53”, Rails receives the following parameters:

{
  "product[available_at(2i)]" => "4", 
  "product[available_at(4i)]" => "12",
  "product[available_at(1i)]" => "2014",
  "product[available_at(3i)]" => "21", 
  "product[available_at(5i)]" => "53", 
}

Which it parses to a hash like this:

{
  :product => {
    "available_at(2i)" => "4", 
    "available_at(4i)" => "12",
    "available_at(1i)" => "2014",
    "available_at(3i)" => "21", 
    "available_at(5i)" => "53", 
  }
}

The parameter keys have extra information about their order (1 through 5) and type (i stands for integer). ActiveRecord will construct an assignment operation like this:

self.available_at = DateTime.new(
    params["available_at(1i)"].to_i,
    params["available_at(2i)"].to_i,
    params["available_at(3i)"].to_i,
    params["available_at(4i)"].to_i,
    params["available_at(5i)"].to_i
)

Ordering and coercing parameters seems reasonable here, but how does it know to use DateTime? Rails looks at the column definition:

column = Product.columns_hash['available_at']
column.type  # => :datetime
column.klass # => DateTime

This information is, of course, read by introspecting the database. This should clarify why this doesn’t work with virtual attributes: they have no column definition to indicate which class should be used to create the property value.

We could go into our controller and try to wrestle the incoming parameters there, and do the sort-coerce-instantiate routine there and just pass our model a nice DateTime object, but down that path madness lies. Instead, let’s just tell Rails to use the logic it already has for constructing multi-parameter values:

class Product < ActiveRecord::Base
  self.columns_hash['available_at'] = OpenStruct.new(type: :datetime, klass: DateTime)
end

Technically, we only need our fake column object to respond to the klass method, but also responding to type will help form builders such as simple_form to decide on which controls to use.

Metaprogramming to the rescue (of course)

So we’ve got some custom code to make our virtual available_at column work. Here’s the full picture:

class Product < ActiveRecord::Base
  store :properties,
        acessors: %i(color size available_on),
        coder: JSON
  
  self.columns_hash['available_at'] = OpenStruct.new(type: :datetime, klass: DateTime)
  
  def available_at
    return super.to_datetime if super.respond_to?(:to_datetime)
    DateTime.parse(super)
  end
end

It is not terrible, but introduce multiple datetime fields and you’ll quickly clutter up your model definition. Enter concerns and metaprogramming:

module ProductProperties
  extend ActiveSupport::Concern

  PROPERTIES = %i(color size available_at).freeze

  included do
    store :properties,
          acessors: PROPERTIES,
          coder: JSON
  
    PROPERTIES.map(&:to_s).grep(/_at$/).each do |property|
      self.columns_hash[property] = OpenStruct.new(type: :datetime, klass: DateTime)

      define_method property do
        return super.to_datetime if super.respond_to?(:to_datetime)
        DateTime.parse(super)
      end
    end
  end      
end

class Product < ActiveRecord::Base
  include ProductProperties
end

We use a module that will loop over all properties ending in _at and define the proper fake column definitions and reader methods for us. This concept is easily extended to columns ending in _on, which should use the Date class instead.

Conclusion

Introducing more plain Ruby to Rails projects, such as custom attribute accessor methods that do not necessarily map directly to database columns, reveals the extent to which Rails (and especially ActiveRecord) is coupled to the database. That allows us a great deal of speed and focus on some areas; but sometimes you’ll really have to dive in and figure out a way to work around the framework. But I think that’s a reasonable price to pay.

Arjan van der Gaag

Arjan van der Gaag

A thirtysomething software developer, historian and all-round geek. This is his blog about Ruby, Rails, Javascript, Git, CSS, software and the web. Back to all talks and articles?

Discuss

You cannot leave comments on my site, but you can always tweet questions or comments at me: @avdgaag.