ruby-on-railsclass-methodactivesupport-concern

ActiveRecord::Concern and base class scope


I have Filterable module that creates scopes usesd by various models,

module Filterable
  extend ActiveSupport::Concern

  included do
    # References
    scope :filter_eq_reference, ->(val){ where(reference: val) }
    scope :filter_eq_description, ->(val){ where(description: val) }
    scope :filter_eq_ref1, ->(val){ where(ref1: val) }
    scope :filter_eq_ref2, ->(val){ where(ref2: val) }
    scope :filter_eq_ref3, ->(val){ where(ref3: val) }
    scope :filter_eq_ref4, ->(val){ where(ref4: val) }
    scope :filter_eq_ref5, ->(val){ where(ref5: val) }
    scope :filter_eq_ref6, ->(val){ where(ref6: val) }
    # Weights
    scope :filter_eq_weight, ->(val){ where(weight: val) }
    scope :filter_lt_weight, ->(val){ where('weight < ?', val) }
    scope :filter_lte_weight,->(val){ where('weight <= ?', val) }
    scope :filter_gt_weight, ->(val){ where('weight > ?', val) }
    scope :filter_gte_weight,->(val){ where('weight >= ?', val) }
   # ...

  end

  class_methods do
  end
end

I want to refactor it for several reasons #1. it's getting large #2. All models don't share the same attributes

I came to this

module Filterable
  extend ActiveSupport::Concern

  FILTER_ATTRIBUTES = ['reference', 'description', 'weight', 'created_at']

  included do |base|
    base.const_get(:FILTER_ATTRIBUTES).each do |filter|
      class_eval %Q?
        def self.filter_eq_#{filter}(value)
          where(#{filter}: value)
        end
       ?
    end
  end

It works, but I want to have the attributes list in the model class, As issue #2, I think it more their responsability So I moved FILTER_ATTRIBUTES in each class including this module The problem when doiing that, I get an error when calling Article.filter_eq_weight 0.5

NameError: uninitialized constant #Class:0x000055655ed90f80::FILTER_ATTRIBUTES Did you mean? Article::FILTER_ATTRIBUTES

How can I access the base class - having 'base' injected or not doesn't change anything

Or maybe better ideas of implementation ? Thanks


Solution

  • I would suggest a slightly different approach. You already see that you are going to be declaring a list of filters in each class that wants to filter based on some value. Stepping back a bit, you may also realize that you are going to create different types of filters depending on the type of value of the field. With that in mind, I would consider stealing a page from the Rails playbook and create class methods that create the filters for you.

    module Filterable
      extend ActiveSupport::Concern
    
      class_methods do
        def create_equality_filters_for(*filters)
          filters.each do |filter|
            filter_name = "filter_eq_#{filter}".to_sym
            next if respond_to?(filter_name)
    
            class_eval %Q?
              def self.filter_eq_#{filter}(value)
                where(#{filter}: value)
              end
            ?
          end
        end
    
        def create_comparison_filters_for(*filters)
          filters.each do |filter|
            { lt: '<', lte: '<=', gt: '>', gte: '>=' }.each_pair do |filter_type, comparison|
              filter_name = "filter_#{filter_type}_#{filter}".to_sym
              puts filter_name
              next if respond_to?(filter_name)
    
              class_eval %Q?
                def self.filter_#{filter_type}_#{filter}(value)
                  where('#{filter} #{comparison} \?', value)
                end
              ?
            end
          end
        end
      end
    end
    

    Then you would use it like this:

    class Something < ApplicationRecord
      include Filterable
    
      create_equality_filters_for :reference, :description, :weight
      create_comparison_filters_for :weight
    end
    
    Something.methods.grep(/filter_/)
    [:filter_eq_reference, :filter_eq_description, :filter_eq_weight, :filter_lt_weight, :filter_lte_weight, :filter_gt_weight, :filter_gte_weight]
    

    This approach makes your code significantly more declarative -- another developer (even yourself in a year!) won't wonder what the FILTER_ATTRIBUTES constant does or why (if?) it's required (a potential problem when the consuming code is not part of the class you are reading). Though future-you may not remember where the create_xyz methods are defined or exactly how they do what they do, the method names make fairly clear what is being done.