Extract common model code to modules


In a previous post I wrote a post about how to do this and this post goes through some more ways to achieve the same result in a slightly more succint way. In order to use a module in this case I have to define a path in rails in which to find the module. I need to go to application.rb and add in this line of code:

config.autoload_paths += %W(#{config.root}/lib)

This is essentially saying to add the array (%w is a notation to write an array of strings eg %w(foo bar) returns ['foo', 'bar']) into the autoload paths. config.root is the application root path and lib is the folder under this.

In my application I have repeated code for counting votes in my Post and Comment models. I can extract this to a voteable.rb file under the lib file and create a module called Voteable:

module Voteable
  #this means that all my instance methods in here are going to be instance methods when I include or mixin this module
  extend ActiveSupport::Concern

  def total_votes
    up_votes - down_votes
  end

  def up_votes
    self.votes.where(vote: true).size
  end

  def down_votes
    self.votes.where(vote: false).size
  end

end

Now just type include Voteable in any class you want to use it in.

Other redundant code in my models is has_many :votes, as: :voteable so ActiveSupport::Concern gives me the ability to include this in my Voteable module by doing the following:

included do
  has_many :votes, as: :voteable
end

Using Concerns is a rails specific way to do it. Another traditional Ruby metaprogramming approach would be to do the following instead:

module Voteable
  def self.included(base)
    base.send(:include, InstanceMethods)
    base.extend ClassMethods
    base.class_eval do
      my_class_method
    end
  end
  
  module InstanceMethods
  
    def total_votes
      up_votes - down_votes
    end

    def up_votes
      self.votes.where(vote: true).size
    end

    def down_votes
      self.votes.where(vote: false).size
    end
  end
  
  module ClassMethods
    def my_class_method
      has_many :votes as: :voteable
    end
  end
end

With the voting taken care of I have some more redundant code to clear up in Post, User and Category for dealing with generating a slug. Once again I create a file in /lib, this time called sluggable.rb that has a module called Sluggable. Now, in my Post model I have to include the model with include Sluggable. This time I have one other step to make and that is to pass into the module the column name that holds the slug name because this will be different for every class that includes my Sluggable module. This is done by adding

sluggable_column :title

It is title in this case but just change this to whatever column you want to base the slug on. Mu moule now looks as follows:

module Sluggable
  extend ActiveSupport::Concern

  #this is run as soon as the module is included
  included do
    before_save :generate_slug!
    class_attribute :slug_column
  end

  #changes to_param method to look for slug rather than the default of id
  def to_param
    self.slug
  end

  def generate_slug!
    #self.class will be the model that includes the module and slug_column is the
    #class attribute set in the class method
    #therefore self.send will equate to self.title or example is title is the class attribute
    the_slug = to_slug(self.send(self.class.slug_column.to_sym))
    obj = self.class.find_by(slug: the_slug)
    count = 2
    #will keep appending a number if the slug name generated is the same as one already set in the database
    #eg. if "something" is already in the database then it will be something-2, etc
    while obj && obj != self
      the_slug = append_suffix(the_slug, count)
      obj = self.class.find_by slug: the_slug
      count += 1
    end
    self.slug = the_slug.downcase
  end

  def append_suffix(str, count)
    if str.split('-').last.to_i != 0
      return str.split('-').slice(0...-1).join('-') + '-' + count.to_s
    else
      return str + "-" + count.to_s
    end
  end

  def to_slug(name)
    str = name.strip
    str.gsub!(/\s*[^A-Za-z0-9]\s*/, '-') #replace all non alphnumerics with a -
    str.gsub!(/-+/, '-') #replace consequtive - with a single -
    str.gsub!(/(^-+)|(-+$)/, '') #strip out - at start and end of string
    str.downcase
  end

  #the class method is called as soon as the module is included due to sluggable_column in the model
  #the column name passed in from the model is used to set the slug_column class_attribute
  module ClassMethods
    def sluggable_column(column_name)
      self.slug_column = column_name
    end
  end
end

I've commented this fairly heavily to make it easier to understand what is going on but it is quite complicated and involves some metaprogramming which is quite new to me. Now I just include the module in the other models I want to use it in, remembering to set the sluggable_column each time.