Igor Alexandrov

Programming and something else…

Rails Presenters and Filters

| Comments

Part 1: Introduction and Filters

How ofter do you use Presenter Patter in your Rails applications? I do it when I have really complex views and want to move all logic from template file to some Ruby class. Usually this can be done when you have listing of items with paginations, per page selector sorting and filter for these items. In this post I want to show you my approach for such situations.

Lets take a look at the screenshot.

For my not-Russian speaking readers: this is a screenshot from a www.sdelki.ru site, which is a service for a small and medium business in Russia and CIS-countries. Companies can import their product catalog, get reviews, find partners, upload documents and so on.

Let’s start with filter implementation. From my point of view, filter should only do search. It should not do pagination, ordering or maybe counting. In other words it should return ActiveRecord::Relation that can be further used in presenters or maybe directly in your controllers.

Of course you can use existing gems for this task like MetaSearch or its successor RanSack, but I will show you how to build solution that will be as easy to use as these gems but much more configurable. I believe that using your own DSL inside of your classes is a good tradition. DSL makes your code more readable and of course more easier to change. This is FilterConfigurator class, that implements DSL for our filters.

lib/filter_configurator.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
class FilterConfigurator
  def initialize(filter, &block)
    @filter = filter
    self.instance_eval(&block)
  end

  def method(*args)
    options = args.extract_options!

    unless options[:attribute] == false
      self.add_filter_attribute(args[0])

      @filter.send(:attribute, args[0], args[1], options)

      self.add_json_attribute(args[0]) unless options[:json] == false
    end

    self.add_filter_method(args[0])
  end

  def attribute(*args)
    options = args.extract_options!

    self.add_filter_attribute(args[0])

    @filter.send(:attribute, args[0], args[1])

    self.add_json_attribute(args[0]) unless options[:json] == false
  end

protected

  def add_filter_method(argument)
    @filter.filter_methods << argument

    # Define method only if it does not already exist
    method = "filter_#{argument.to_s}"

    unless @filter.method_defined?(method)
      @filter.send :define_method, method do
        value = send(argument)

        value.present? ? { argument.to_sym => value } : nil
      end
      @filter.send(:protected, method)
    end
  end

  def add_filter_attribute(argument)
    @filter.filter_attributes << argument
  end

  def add_json_attribute(argument)
    @filter.json_attributes << argument
  end
end

Let’s add a base filter class. Please note that this implementation uses Virtus to define filter attributes, but you can easily do this without it by nesting from ActiveRecord::Base and overriding #column method. Also Filter uses logic from Squeel gem in #add_condition method, but this can also be rewritten, if you don’t like Squeel for any reason.

lib/filter.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
require 'filter_configurator'
require 'virtus'
require 'yajl'

class Filter
  include Virtus

  attr_accessor :user, :action

  class << self
    attr_accessor :filter_methods, :filter_attributes, :json_attributes

    def define_filter(&block)
      self.filter_methods     ||= []
      self.filter_attributes  ||= []
      self.json_attributes    ||= []

      FilterConfigurator.new(self, &block)
    end
  end

  # uncomment if you will use this with CanCan
  # def action
  #   @action || :update
  # end

  def results(options = {})
    s = self.scope.where(self.find_criteria(options))
    # uncomment if you will use this with CanCan
    # s = s.accessible_by(self.user.ability, self.action) if self.user.present?
    s
  end

  def to_json
    Yajl::Encoder.encode(Hash[self.class.json_attributes.map { |a| value = send(a); [a, send(a)] if value.present? }])
  end

  def self.from_json(json)
    attributes = (Yajl::Parser.parse(json)).slice(*self.json_attributes.map(&:to_s)) rescue nil
    self.new(attributes)
  end

  def update(attributes = {}, options = {})
    options.reverse_merge!({
      :nullify => false
    })

    if !!options[:nullify]
      nullifying = self.class.filter_attributes - attributes.keys
      nullifying.each{ |attr| self.send("#{attr}=", nil) }
    end

    self.attributes = attributes
  end

protected

  def add_condition(query, condition)
    query.nil? ? condition : query & condition
  end

  def find_criteria(options = {})
    options[:except] = Array.wrap(options[:except])

    conditions = nil

    (self.class.filter_methods - options[:except]).each do |m|
      begin
        value = send("filter_#{m.to_s}")
        conditions = self.add_condition(conditions, value) unless value.nil?
      rescue
      end
    end
    conditions
  end
end

Filter and all classed that are inherited from it now have class method #define_filter which accepts block. Inside this block you can call #method or #attribute methods to configure your filter.

1
2
3
4
define_filter do
  method :price, Float
  attribute :currency, String
end

This code will do the following:

  1. add both :price and :currency to attributes list in Virtus with proper types;
  2. add both :price and :currency to JSON-exportable list of attributes;
  3. create method #filter_price if it does not exist as
    1
    2
    3
    4
    
    def filter_price
      value = self.price
      value.present? ? { :price => value } : nil
    end
    

As you can see Filter can be serialized to JSON, this may help you when you will have a task to save user searches.

Now let’s inherit a real filter from a Filter class. This filter can be used to find proposals (Proposal is one of the main objects in Sdelki, it stores information about one product). Of course this filter is a bit simplified to be more readable.

lib/proposals/filter.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
require 'lib/filter'

class Proposals::Filter < Filter
  attr_accessor :company

  define_filter do
    method :ids, Array, :json => false

    method :brand_ids, Array, :default => []
    method :category_ids, Array, :default => []

    method :status, String
    method :has_photo, Boolean

    method :price, Float
    attribute :currency, String

    method :title, String
  end

  def initialize(attributes = {})
    super

    self.currency ||= Currency.default_code
  end

  def scope
    Proposal.not_deleted.of_company(self.company)
  end

protected

  def filter_brand_ids
    if self.brand_ids.present?
      blank_allowed = self.brand_ids.include?(-1)

      blank_allowed ? { :brand_id.in => self.brand_ids } | { :brand_id.eq => nil } : { :brand_id.in => self.brand_ids }
    end
  end

  def filter_category_ids
    if self.category_ids.present?
      blank_allowed = self.category_ids.include?(-1)

      blank_allowed ? { :category_id.in => self.category_ids } | { :category_id.eq => nil } : { :category_id.in => self.category_ids }
    end
  end

  def filter_has_photo
    if self.has_photo?
      { :photos_count.gt => 0 }
    end
  end

  def filter_price
      if self.price.present?
        if self.currency.present?
          converted_price = Currency.send(self.currency).convert(self.price)
          { :price => converted_price }
        else
          { :price => self.price }
        end
      end
  end  
  
  def filter_title
      { :title.matches => self.title } if self.title.present?
  end
end

Nearly all #filter_* methods, except to #filter_status are redefined. You can put any custom logic that you like to your search.

Example

1
2
3
4
5
6
1.9.3-p327 :009 > f = Proposals::Filter::Office.new(:company => Company.find(71902))
  Company Load (0.7ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT 1  [["id", 71902]]
 => #<Proposals::Filter::Office ids: nil, brand_ids: [], category_ids: [], country_of_production_ids: [], group_ids: [], group_id: nil, status: nil, import_id: nil, has_photo: nil, price: nil, min_price: nil, max_price: nil, currency: "RUB", proposal_type: nil, condition: nil, used: nil, min_year_of_production: nil, max_year_of_production: nil, title: nil, only_mine: nil> 
1.9.3-p327 :010 > f.results[0]
  Proposal Load (17.5ms)  SELECT "proposals".* FROM "proposals" LEFT OUTER JOIN "proposal_prices" ON "proposal_prices"."proposal_id" = "proposals"."id" AND "proposal_prices"."current" = 't' WHERE "proposals"."deleted" = 'f' AND "proposals"."company_id" = 71902 AND "proposals"."company_id" = 71902
 => #<Proposal id: 95374, title: "UNITED NUDE сабо 7280512727 на необычной танкетке", category_id: 26419, company_id: 71902, description: "", proposal_type_id: 2, moderation_status_message: nil, created_at: "2012-12-31 06:38:48", updated_at: "2012-12-31 07:40:22", photos_count: 1, expiration_days: 30, upped_at: "2012-12-31 07:40:22", updated_by_user_at: "2012-12-31 06:38:48", views_count: 0, minimal_batch_value: nil, minimal_batch_unit_information: nil, delivery_time_information: nil, moderation_status_changed_at: nil, prices_count: 1, contact_user_id: 72451, user_id: 72451, status: "a", deleted: false, group_id: 4460, import_id: 224, document_ids_cached: nil, city_id: 981, brand_id: nil, external_id: 5885940, street_address: nil, condition: "n", year_of_production: nil, country_of_production_id: nil, tagged_description: nil, enabled: true, delta: true> 

Or a bit more complex

1
2
3
4
5
6
1.9.3-p327 :017 >   f = Proposals::Filter::Office.new(:company => Company.find(71902), :min_price => 1000, :category_ids => [2,5,19])
  Company Load (0.9ms)  SELECT "companies".* FROM "companies" WHERE "companies"."id" = $1 LIMIT 1  [["id", 71902]]
 => #<Proposals::Filter::Office ids: nil, brand_ids: [], category_ids: [2, 5, 19], country_of_production_ids: [], group_ids: [], group_id: nil, status: nil, import_id: nil, has_photo: nil, price: nil, min_price: 1000.0, max_price: nil, currency: "RUB", proposal_type: nil, condition: nil, used: nil, min_year_of_production: nil, max_year_of_production: nil, title: nil, only_mine: nil> 
1.9.3-p327 :018 > f.results
  Proposal Load (30.2ms)  SELECT "proposals".* FROM "proposals" LEFT OUTER JOIN "proposal_prices" ON "proposal_prices"."proposal_id" = "proposals"."id" AND "proposal_prices"."current" = 't' WHERE "proposals"."deleted" = 'f' AND "proposals"."company_id" = 71902 AND (("proposals"."company_id" = 71902 AND "proposals"."category_id" IN (2, 5, 19)))
 => []

Looks good, yes? Simple and easy to understand.

To be continued…

Comments