RUBY ON RAILS: ACTIVE RECORD FACETS AND FILTERS FOR SEARCHING (WITHOUT USING ANY DEPENDENCIES SUCH AS ELASTIC SEARCH OR SOLR)

By: Saurav

2018-01-24 07:07:00 UTC

Hello! I feel really happy to be able to solve this problem and wanted to share it with you.

I was working with incorporating facets and filters and tags on search results. That is one of the most important and most complex feature of any e-commerce/product/service focused website. You probably have seen it using booking/travel/amazon/cars.com etc.

I have been researching for it for a long time, asked on Stack Overflow but couldn't find any apt material. The only help or suggestions were to use elastic search/ Solr as they have integrated facets and filtering and anyone can use it easily. But that was not what I wanted.

I wanted to learn the basics of building facets from scratch and be able to use logic instead of a service for this requirement. Also, Elastic search or Solr indexing is best suited for text best searching. Using it just for the sake of leveraging one of its functionality is not what I wanted to do. I believe the less you are dependent on other services to solve the problems and the fewer dependencies you have in any project, the more you will learn and the better you will be able to understand what went wrong in your app. Finally after reading a lot of blogs, reading answers on SO and raising a bounty on the question on SO and help from Pablo (credit in the bottom) on SO, I was able to learn to build it from scratch.

Note: Normally I write inline CSS elements and runnable code while in development and after everything works perfectly, I go ahead and refactor my code and separate CSS before pushing to production. The code used here has not gone through final refactoring stages.

Let us start from the point where I was stuck and build on it.

Making a Search in rails is easy:

1. You write a class function which will take the params and spits out results using active record queries
2. You give controller the task of handling a request for search and providing params to that search class method
3. You provide route and match the search action in the controller to that route for receiving the request from a user
4. Depending upon where you want the results to be shown, you provide a search page if needed.

let's deal with these components first:

For the case of a working example, let's say we have an entity called listing which mainly is used to store cars or motorcycles. The schema is shown below:

Schema:

create_table "listings", force: :cascade do |t|
    t.string   "title",                  limit: 255
    t.text     "description",            limit: 65535
    t.string   "city",                   limit: 255
    t.string   "state",                  limit: 255
    t.string   "zipcode",                limit: 255
    t.datetime "created_at"
    t.datetime "updated_at"
    t.integer  "category_id",            limit: 4
    t.integer  "subcategory_id",         limit: 4
    t.float    "latitude",               limit: 24
    t.float    "longitude",              limit: 24
    t.integer  "user_id",                limit: 4
    t.string   "image",                  limit: 255
    t.integer  "year",                   limit: 4
    t.integer  "miles",                  limit: 4
    t.string   "transmission",           limit: 255
    t.string   "color",                  limit: 255
    t.string   "cylinder",               limit: 255
    t.string   "fuel",                   limit: 255
    t.string   "drive",                  limit: 255
    t.string   "address",                limit: 255
    t.boolean  "wholesale",                            default: false
    t.integer  "price",                  limit: 4,     default: 0
    t.string   "newused",                limit: 255
    t.string   "vin",                    limit: 255
    t.string   "stocknumber",            limit: 255
    t.string   "model",                  limit: 255
    t.string   "trim",                   limit: 255
    t.string   "enginedescription",      limit: 255
    t.string   "interiorcolor",          limit: 255
    t.text     "options",                limit: 65535
    t.string   "imagefront",             limit: 255
    t.string   "imageback",              limit: 255
    t.string   "imageleft",              limit: 255
    t.string   "imageright",             limit: 255
    t.string   "frontinterior",          limit: 255
    t.string   "rearinterior",           limit: 255
    t.string   "bodytype",               limit: 255
    t.string   "make",                   limit: 255
    t.boolean  "approved",                             default: false
    t.datetime "expiration_date",                      default: '2218-01-03 01:44:43'
    t.boolean  "external_url",                         default: false
    t.string   "external_image",         limit: 255
    t.string   "external_imagefront",    limit: 255
    t.string   "external_imageback",     limit: 255
    t.string   "external_imageleft",     limit: 255
    t.string   "external_imageright",    limit: 255
    t.string   "external_frontinterior", limit: 255
    t.string   "external_rearinterior",  limit: 255
  end

Note: I am using mysql2 gem instead of sqlite.

Lets look at the search class method in the model Listing:

Model Class Method:

class Listing < ActiveRecord::Base
  def self.search(params)
		if params
			listings = Listing.where(approved: true).where('expiration_date > ?', DateTime.now)
			listings = listings.where('LOWER(listings.make) like ?', "%#{params[:category].downcase}%") if params[:category].present?
			listings = listings.where('LOWER(listings.model) like ?',"%#{params[:subcategory].downcase}%") if params[:subcategory].present?
			listings = listings.where("listings.NewUsed = '#{params[:NewUsed][0].upcase}'") if params[:NewUsed].present?
			listings = listings.where("price >= ?", "#{params[:minprice]}") if params[:minprice].present?			
			listings = listings.where("price <= ?", "#{params[:maxprice]}") if params[:maxprice].present?			


			listings = listings.where('LOWER(listings.bodytype) like ?', "%#{params[:bodytype].downcase}%") if params[:bodytype].present?
					

			if params[:radius].present?
				listings = listings.near(params[:location],params[:radius]) if params[:location].present?
			else
				listings = listings.near(params[:location],200) if params[:location].present?
			end

			listings.uniq

		else			
			all
		end

	end
end

The controller action looks like:

Controller Action:

class ListingsController < ApplicationController
  	def search		
		@listings = Listing.search(params)	
        end
end

The route will use a collection as shown below:

Route:

Rails.application.routes.draw do    
  resources :listings do
      collection do
      get 'search'
  end
end

The search form and the search page looks like this:

Note: I am using low-quality images to save some space on AWS...lol

Wacbac1

Wacbac2

The codes for the search form looks like this(excluding javascript):

<div class="searchbox">   

    <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12" >

      <div class="tab">
        <button class="tablinks1" onclick="openCity1(event, 'Make')" id="defaultOpen1" >Search Cars By Make</button>
        <button class="tablinks1" onclick="openCity1(event, 'Type')">Search Cars By Type</button>  
        <button class="tablinks1" onclick="openCity1(event, 'ServiceCenter')">Find Repairshops and Service Centers</button>  
        <button class="tablinks1" onclick="openCity1(event, 'Dealerships')">Find Dealers</button>  

        
      </div>

      <hr>


      <div id="Make" class="tabcontent1">
            <section class="formclass">

              <!-- f.select :transmission, ['Automanual','Automatic','Automatic 4 Speed','Automatic 5 Speed','Automatic 6 Speed','CVT','Manual'] -->
                <h3 style = "color:#F00000; text-align: center;"><strong><%= @carcount %> CARS LISTED!</strong></h3>

                <hr>

                <h3 style = "color:#7C064D;"><span class="glyphicon glyphicon-search"></span> <strong>SEARCH CARS FOR SALE BY MAKE</strong></h3>
                <%= form_tag search_listings_path, method: :get, class: 'navbar-form navbar-center' do |f| %>

                <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12"> 
                    <%= select_tag :NewUsed, "<option>New</option><option>Used</option>".html_safe, style: "width: 100%; margin: 1% 0;" %>
                </div>

                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= select_tag :category, include_blank: true, style: "width: 100%; margin: 1% 0;" %>
                </div>

                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= select_tag :subcategory, include_blank: true, style: "width: 100%; margin: 1% 0;" %>
                </div>
                <!-- <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">  -->
                    <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                        <%= text_field_tag :minprice, nil, placeholder: 'Min Price', style: "width: 100%; margin: 1% 0;" %>
                    </div>
                    <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                        <%= text_field_tag :maxprice, nil, placeholder: 'Max Price', style: "width: 100%; margin: 1% 0;" %>
                    </div>
                <!-- </div> -->
                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= text_field_tag :location, nil, placeholder: 'Near', style: "width: 100%; margin: 1% 0;" %>
                </div>
                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= text_field_tag :radius, nil, placeholder: 'Radius', style: "width: 100%; margin: 1% 0;" %>
                </div>
                

                <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">              
                    <%= submit_tag 'Search', class: 'btn btn-danger', style: "width: 100%;" %>
                    <%- if user_is_basic_user  -%>
                        <h3 style = "color:#F00000;"><strong>OR</strong></h3>
                        <%= link_to 'Sell Your Car', new_listing_path , class: 'btn btn-danger another', style: "width: 100%;" %>
                    <% end %>
                </div>

                <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">  
                    
                </div>


                
              <% end %>    
            </section>
      </div>

      <div id="Type" class="tabcontent1">
        <section class="formclass">

                <h3 style = "color:#F00000; text-align: center;"><strong><%= @carcount %> CARS LISTED!</strong></h3>

                <hr>

                <h3 style = "color:#7C064D;"><span class="glyphicon glyphicon-search"></span> <strong>SEARCH CARS FOR SALE BY BODY TYPE</strong></h3>

                <%= form_tag search_listings_path, method: :get, class: 'navbar-form navbar-center' do |f| %>

                <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12"> 
                    <%= select_tag :NewUsed, "<option>New</option><option>Used</option>".html_safe, style: "width: 100%; margin: 1% 0;" %>
                </div>

                <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12"> 
                    <%= select_tag :bodytype, "<option>4dr Hatchback</option><option>2dr SUV </option><option>4dr SUV </option><option>Cargo Minivan </option><option>Cargo Van </option><option>Convertible </option><option>Convertible SUV </option><option>Coup </option><option>Crew Cab Pickup </option><option>Extended Cab Pickup </option><option>Passenger Minivan </option><option>Passenger Van </option><option>Regular Cab Pickup </option><option>Sedan </option><option>Wagon</option>".html_safe, style: "width: 100%; margin: 1% 0;" %>
                </div>                

                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= text_field_tag :minprice, nil, placeholder: 'Min Price', style: "width: 100%; margin: 1% 0;" %>
                </div>
                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= text_field_tag :maxprice, nil, placeholder: 'Max Price', style: "width: 100%; margin: 1% 0;" %>
                </div>
                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= text_field_tag :location, nil, placeholder: 'Near', style: "width: 100%; margin: 1% 0;" %>
                </div>
                <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                    <%= text_field_tag :radius, nil, placeholder: 'Radius', style: "width: 100%; margin: 1% 0;" %>
                </div>                

                <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">              
                    <%= submit_tag 'Search', class: 'btn btn-danger', style: "width: 100%;" %>
                    <%- if user_is_basic_user  -%>
                        <h3 style = "color:#F00000;"><strong>OR</strong></h3>
                        <%= link_to 'Sell Your Car', new_listing_path , class: 'btn btn-danger another', style: "width: 100%;" %>
                    <% end %>
                </div>
              <% end %>    
        </section>
      </div>    


    <div id="ServiceCenter" class="tabcontent1">
        <section class="formclass">

                    <h3 style = "color:#F00000; text-align: center;"><strong><%= @repairshopscount %> REPAIR SHOPS TRUST US!</strong></h3>

                    <hr>

                    <h3 style = "color:#7C064D;"><span class="glyphicon glyphicon-search"></span> <strong>SEARCH REPAIR SHOPS & SERVICE CENTERS NEAR YOU</strong></h3>

                    <%= form_tag search_repairshops_path, method: :get do |f| %>

                     <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">                        
                        <%= text_field_tag :keywords, nil, placeholder: 'Keywords (e.g. enter services you are looking for like: "oil change" or the make of your car, like: "Ford)', style: "width: 100%; margin: 1% 0;" %>
                    </div>

                    <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6">                        
                        <%= text_field_tag :location, nil, placeholder: 'Near', style: "width: 100%; margin: 1% 0;" %>
                    </div>
                    <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                        <%= text_field_tag :radius, nil, placeholder: 'Radius', style: "width: 100%; margin: 1% 0;" %>
                    </div>
                    <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12"> 
                        <%= submit_tag 'Search', class: 'btn btn-danger', style: "width: 100%;" %>
                        
                        <%- if user_is_diamond_or_repairshop -%>
                            <h3 style = "color:#F00000;"><strong>OR</strong></h3>
                            <%= link_to 'Add Your Shop', new_repairshop_path , class: 'btn btn-danger another', style: "width: 100%;" %>
                        <% end %>
                        
                    </div>
                  <% end %>    
        </section>
    </div> 

    <div id="Dealerships" class="tabcontent1">
        <section class="formclass">

                    <h3 style = "color:#F00000; text-align: center;"><strong><%= @repairshopscount %> DEALERS TRUST US!</strong></h3>

                    <hr>

                    <h3 style = "color:#7C064D;"><span class="glyphicon glyphicon-search"></span> <strong>SEARCH DEALERS NEAR YOU</strong></h3>

                    <%= form_tag dealer_search_listings_path, method: :get do |f| %>

                     <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">                        
                        <%= text_field_tag :keywords, nil, placeholder: 'Keywords (e.g. Dealer name or Make of Car or Model)', style: "width: 100%; margin: 1% 0;" %>
                    </div>

                    <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6">                        
                        <%= text_field_tag :location, nil, placeholder: 'Near', style: "width: 100%; margin: 1% 0;" %>
                    </div>
                    <div class= "col-xs-6 col-sm-6 col-lg-6 col-md-6"> 
                        <%= text_field_tag :radius, nil, placeholder: 'Radius', style: "width: 100%; margin: 1% 0;" %>
                    </div>
                    <div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12"> 
                        <%= submit_tag 'Search', class: 'btn btn-danger', style: "width: 100%;" %>
                    </div>
                  <% end %>    
        </section>
    </div>  



  </div>

</div>

Now that we are done with the setup and current state of development, let's focus on how to generate the facets:

The problem can be broken down into two parts:
1. Get the count of listings by using group by for each required fields and show it to the users as facets according to the search results.
2. Use the facets as links to refine the results by providing params for filtering what you already have (Keep in mind you can use multiple filtering links and should be able to remove them as well.)

To solve the first part I used scopes in the model to group listings by the required attributes as shown below:

	scope :listing_search_cities, -> {group(:city).count}
	scope :listing_search_body_type, -> {group(:bodytype).count}
	scope :listing_search_states, -> {group(:state).count}
	scope :listing_search_newused, -> {group(:NewUsed).count}
	scope :listing_search_transmission, -> {group(:transmission).count}
	scope :listing_search_cylinder, -> {group(:cylinder).count}
	scope :listing_search_fuel, -> {group(:fuel).count}
	scope :listing_search_drive, -> {group(:drive).count}
	scope :listing_search_trim, -> {group(:trim).count}
	scope :listing_search_color, -> {group(:color).count}
	scope :listing_search_year, -> {group(:year).count}
	scope :listing_search_interiorcolor, -> {group(:interiorcolor).count}

The controller then applies these scopes to the search results:

@listings_cities = @listings.listing_search_cities	
@listings_bodytype = @listings.listing_search_body_type
@listings_states = @listings.listing_search_states
@listings_newused = @listings.listing_search_newused
@listings_cylinder = @listings.listing_search_cylinder
@listings_transmission = @listings.listing_search_transmission
@listings_drive = @listings.listing_search_drive
@listings_trim = @listings.listing_search_trim
@listings_fuel = @listings.listing_search_fuel
@listings_color = @listings.listing_search_color
@listings_interiorcolor = @listings.listing_search_interiorcolor
@listings_year = @listings.listing_search_year

keep in mind that group(:attr).count returns the name and the count of each entity in a key-value hash.

So, in the view we can iterate through each of the pairs and show it for each facet type:

Let's see an example for cities facets for the search results:

<div class = "col-xs-12 col-sm-12 col-md-12 col-lg-12" id = "searchboxcontainer2" style = " margin-top: 2em; -webkit-box-shadow: 1px 1px 2px 2px #ccc; -moz-box-shadow: 1px 1px 2px 2px #ccc; box-shadow: 1px 1px 2px 2px #ccc;  padding: 5px;">   

          <h3 style = "color:#7C064D; text-align: center;"><strong>CITIES</strong></h3>

          
            <%- @listings_cities.each do |key, value| -%>
              <p><%= key.upcase %>(<%= value %>)</p>
            <% end %>
          
          
                          
      </div>

Filtering:
To be able to make each facet respond to filtering results, we will need to have another hash which stores the filters params and filter out results accordingly.

As shown in the code below, current_filter is an instance variable which holds filter params. We check for the presence of each filter params and launch a filter query according to the results we already have.

def search
		@listings = Listing.search(params)	

		#Applied filters

		@current_filters = params[:filters] || {:filters => ""}		

		if params[:filters]

	  		@listings = @listings.where(:cylinder => @current_filters[:cylinder]) if @current_filters[:cylinder]	  		

	  		@listings = @listings.where(:bodytype => @current_filters[:bodytype]) if @current_filters[:bodytype]
	  		@listings = @listings.where(:city => @current_filters[:city]) if @current_filters[:city]
	  		@listings = @listings.where(:state => @current_filters[:state]) if @current_filters[:state]
	  		@listings = @listings.where(:transmission => @current_filters[:transmission]) if @current_filters[:transmission]
	  		@listings = @listings.where(:newused => @current_filters[:newused]) if @current_filters[:newused]
	  		@listings = @listings.where(:fuel => @current_filters[:fuel]) if @current_filters[:fuel]
	  		@listings = @listings.where(:drive => @current_filters[:drive]) if @current_filters[:drive]
	  		@listings = @listings.where(:year => @current_filters[:year]) if @current_filters[:year]

	  		@listings = @listings.where(:interiorcolor => @current_filters[:interiorcolor]) if @current_filters[:interiorcolor]	  		
	  	end

	  	@listings_cities = @listings.listing_search_cities	
		@listings_bodytype = @listings.listing_search_body_type
		@listings_states = @listings.listing_search_states

		@listings_newused = @listings.listing_search_newused
		@listings_cylinder = @listings.listing_search_cylinder
		@listings_transmission = @listings.listing_search_transmission
		@listings_drive = @listings.listing_search_drive
		@listings_trim = @listings.listing_search_trim

		@listings_fuel = @listings.listing_search_fuel

		@listings_color = @listings.listing_search_color
		@listings_interiorcolor = @listings.listing_search_interiorcolor
		@listings_year = @listings.listing_search_year

	end

Now, we need to make each facet as a link to add the filter:

<div class = "col-xs-12 col-sm-12 col-md-12 col-lg-12" id = "searchboxcontainer2" style = " margin-top: 2em; -webkit-box-shadow: 1px 1px 2px 2px #ccc; -moz-box-shadow: 1px 1px 2px 2px #ccc; box-shadow: 1px 1px 2px 2px #ccc;  padding: 5px;">   

          <h3 style = "color:#7C064D; text-align: center;"><strong>YEAR</strong></h3>
            <%- @listings_cities.each do |key, value| -%>
              <%= link_to "#{key.to_str.upcase if key} (#{value})" , search_listings_path(filters: @current_filters.merge(:year => "#{key}")), {:class => "btn btn-danger another"} %>
            <% end %>
          
          
                          
      </div>

Finally, we will need to show the filter params that has been applied to the results and have a link to remove it as well:

<div class= "col-xs-12 col-sm-12 col-lg-12 col-md-12">        

          <h5 style = "color:#E00000; text-align: left;"><strong>APPLIED FILTERS</strong></h5>

              <% @current_filters.each do |key, value| %>  

                <% if value != ""  %>
                   
                   <%= link_to "#{value.upcase}" , search_listings_path(filters: @current_filters.except(key)), {:class => "btn btn-danger applied"} %>

                <% end %>

              <% end %>

        </div>

lets see it in action:

Wacbac3

Figure above shows a typical search result we get at first

Wacbac4

As shown above, on the left we have facets for each year. These are available as links.

Wacbac5

As shown above, our results got filtered and we have a new tag in applied filter

Wacbac6

We can remove any filter in any order by clicking on the tags in the applied filter.

Also, notice below that the facets now shows counts for the filtered results.

Wacbac9

We can also apply multiple filters which will add more tags and will filter our results more:

Wacbac10

Looks good to me.


Let me know what you think or if you have any suggestions.
twitter: sprakash24oct
linkedin

Credits:
Thanks to Pablo who helped me solve the filtering and tag problem through SO.

Owned & Maintained by Saurav Prakash

If you like what you see, you can help me cover server costs or buy me a cup of coffee though donation :)