Home > Software engineering >  ActiveRecord::Concern and base class scope
ActiveRecord::Concern and base class scope

Time:11-05

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

CodePudding user response:

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.

CodePudding user response:

You could just create a simple module that defines a method to construct all of these filters and then call it with a list of filterable attributes.

module Filterable 
  def add_filters(*filters)
     filters.each do |filter| 
       define_singleton_method("filter_eq_#{filter}"){ |val| where(filter => val) }
     end 
  end 
end 

Then

class MyClass < ApplicationRecord 
  extend Filterable 
  add_filters :reference, :description, :weight, :created_at
end 

Simple Example

You could make this more comprehensive as (this solution utilizes Arel::Predications to build query filters so you can expand based on that list)

module Filterable 
  FILTERS = [:eq,:gt,:lt,:gteq,:lteq]

  def add_filters(*fields, filters: Filterable::FILTERS)
    raise ArgumentError, "filter must be one of #{Filterable::FILTERS}" if filters.any? {|filter| !Filterable::FILTERS.include?(filter)} 
    filters.each do |predicate| 
     fields.each do |field| 
       _add_filter(predicate,field)
     end 
    end 
  end 

  def _add_filter(predicate,field) 
    define_singleton_method("filter_#{predicate}_#{field}") do |val|  
      where(arel_table[field].public_send(predicate,val))
    end
  end
end 

Then call as

class MyClass < ApplicationRecord 
  extend Filterable 
  # add equality only filters
  add_filters :description, :ref1, :ref2, :ref3, filters: [:eq]
  # add all filters
  add_filters :weight, :height, :created_at 
end 
  • Related