Extending ActiveModel via ActiveSupport::Concern

In a project I’ve been working on we have some functionality that is shared across multiple models – assigning a default record on a has_many association. Obviously we didn’t want to duplicate the methods in each model, so we explored our options for sharing the logic. We figure we could:

  1. Setup a HasDefault base class that we could extend our models from (i.e. class CreditCard < HasDefault)
  2. Extend the functionality of ActiveRecord::Base

Inspired by how has_secure_password is setup, we decided to extend the functionality of ActiveRecord::Base so we could just call a method inside of a model we wanted to expose the default record assignment methods and callbacks to. Here’s what we ended up with:

File: lib/active_model_extensions.rb

module HasDefault
  extend ActiveSupport::Concern

  module ClassMethods
    def has_default
      attr_accessible :is_default

      before_create  :set_default_if_none_exists, unless: :is_default?
      before_save    :set_default,                if: :is_default?
      before_destroy :set_fallback_default,       if: :is_default?
    end
  end

  def set_default
    current_default = self.class.first conditions: { user_id: self.user_id, is_default: true }
    current_default.update_column(:is_default, false) if current_default
    self.update_column(:is_default, true) unless self.new_record?
  end

  def set_fallback_default
    if self.is_default?
      fallback = self.class.first conditions: { user_id: self.user_id, is_default: false }
      fallback.update_column(:is_default, true) if fallback
    end
  end

  def set_default_if_none_exists
    current_default = self.class.first conditions: { user_id: self.user_id, is_default: true }
    self.is_default = true unless current_default
  end
end

class ActiveRecord::Base
  include HasDefault
end

This allows us to call has_default in any model to expose the three callback methods for assigning the default record. Now all we need to do to in our model is

class CreditCard < ActiveRecord::Base
    has_default
end

and the default record assignment will be taken care of automatically. This assumes there is an is_default column in the table, but has_secure_password makes similar assumptions so that doesn’t really bother me.

The Breakdown

The big thing here is to make sure we are using ActiveSupport::Concern to extend the core Rails classes. This makes our lives a litter easier by helping us setup our class and instance methods. It takes everything inside the ClassMethods module and makes them available as, you guessed it, class methods. All the other methods in the HasDefaultmodule get turned into instance methods.

From the code above, you can see we setup the has_default class methods

module ClassMethods
  def has_default
    attr_accessible :is_default

    before_create  :set_default_if_none_exists, unless: :is_default?
    before_save    :set_default,                if: :is_default?
    before_destroy :set_fallback_default,       if: :is_default?
  end
end

and then setup the methods used in the callbacks in the HasDefault module.

def set_default
  current_default = self.class.first conditions: { user_id: self.user_id, is_default: true }
  current_default.update_column(:is_default, false) if current_default
  self.update_column(:is_default, true) unless self.new_record?
end

def set_fallback_default
  if self.is_default?
    fallback = self.class.first conditions: { user_id: self.user_id, is_default: false }
    fallback.update_column(:is_default, true) if fallback
  end
end

def set_default_if_none_exists
  current_default = self.class.first conditions: { user_id: self.user_id, is_default: true }
  self.is_default = true unless current_default
end

Note: ActiveSupport::Concern used to support a module called InstanceMethods, but recently changed this to make anything outside of the ClassMethods module an instance method.

The last part of this example includes the HasDefault module in ActiveRecord::Base, which actually adds the module and exposes the has_default method to all classes extending ActiveRecord::Base, in other words, all of your models.

class ActiveRecord::Base
  include HasDefault
end

Why ActiveSupport?

It’s true, there are other ways to extend classes in Ruby, but using ActiveSupport::Concern makes things a little easier and definitely cleaner. It can also handle dependency resolution, which we didn’t need here, but is quite useful.

Source:

forums.ubisoft.com
wikidot.com
zippyshare.com