Search is one of those components that almost every app developer will have to build at some point. But not many consider scalability when they create theirs. Here’s how to ensure your search function can be easily maintained in the future.
Nowadays people expect searches to be extensive (usually through an advanced search mechanism), allowing them to refine what they are looking for in multiple ways.
Now, from the concept point of view, a search function is simple:
- The user (via the UI) enters their search criteria.
- Those criteria are sent to the server.
- The server interrogates the database and tries to find the best answers.
- The answers are displayed back to the user in a timely manner.
This may work great in the early days, but you have to remember that your search will evolve alongside your app. The more data you store, the more criteria you will need to offer your users to refine with. And that’s where the issues come in.
Sometimes, when we’ve been asked to rescue someone’s app, they’ll raise the fact that search is slow or not easy to add new things to. At worst it could be a black box and no one is entirely sure how it works.
Here’s how we handle search in our rescue projects – follow our simple process to ensure your search function is scalable too.
Let’s talk about how we can help you develop your existing Ruby on Rails app
Setting the scene
To make things simple, I’ll use a book e-commerce app as an example.
Books have a single title, author and publishing date but can potentially fit many genre categories. They also have a price and a condition that can be ‘new’ or ‘used’.
In this example, we would like our search to be paginated, and we will use Elasticsearch as our document search service.
1. Install and configure Searchkick
Searchkick makes it easy to deal with the Elastisearch DSL. You could end up writing a query like:
Book.search "ruby", where: {condition: "new"}, page: params[:page], per_page: 20
This would search all the books that have fields matching the word Ruby in new condition.
Let’s install Searchkick:
bundle add searchkick
Then add the Searchkick callbacks to our model and start to normalise our search data:
class Book < ApplicationRecord searchkick has_many :categories belongs_to :author scope :search_import, -> { includes(:categories,:author) } def search_data { title: title, description: description, category_ids: category.pluck(:id), author_id: author.id, price: price, condition: condition, published_at: published_at } end end
Finally, let’s reindex our books:
Book.reindex
And voila! We now have our Elasticsearch documents indexed and ready to be searched by our app.
2. Create the route and UI
Now let’s assume that you already have a /books
endpoint and you’d like it to return by default all books ordered by published_at. This endpoint will also receive the search criteria.
This is what your early implementation may look like:
class BooksController < ApplicationController def index @books = Book.all end end
We will now create a BookSearch library and introduce a new gem called Virtus. It allows your Ruby object to behave like ActiveRecord models – that includes attributes and validations.
We set up our BookSearch object like so:
# app/services/book_search.rb or lib/book_search.rb to your preference. class BookSearch include ActiveModel::Model include Virtus.model attribute :query, String, default: "" attribute :author_id, Integer, default: nil attribute :category_id, Integer, default: nil attribute :conditions, Array, default: [] attribute :price_from, Integer, default: nil attribute :price_to, Integer, default: nil attribute :per_page, Integer, default: 25 attribute :paginated, Boolean, default: true attribute :page, Integer, default: 1 end
Some of the attributes will match our model search_data (author_id, category_id) and some are more tailored to our search mechanism (query, conditions as an array, price_to, price_from).
We can now start extending our BookSearch to actually perform the search we want:
# app/services/book_search.rb or lib/book_search.rb to your preference. class BookSearch include ActiveModel::Model include Virtus.model attribute :query, String, default: "" attribute :author_id, Integer, default: nil attribute :category_id, Integer, default: nil attribute :conditions, Array, default: [] attribute :price_from, Integer, default: nil attribute :price_to, Integer, default: nil attribute :per_page, Integer, default: 25 attribute :paginated, Boolean, default: true attribute :page, Integer, default: 1 def run prepare_where_object options = {where: where_object} if paginated options[:page] = page options[:per_page] = per_page end options[:includes] = [:categories, :author] options[:order] = {published_at: :desc} Book.search query_string, options end def query_string # If the user passed a query we will refine using the given word, if not we will take every documents. query.blank? ? "*" : query end def where_object @where ||= {} end def prepare_where_object # This is the bulk of our search method. # We like to separate each possible critera onto its own method # Each of those method will then attach its result to the where_object # This leads to a greater readability & makes it easier to test limit_by_category limit_by_author limit_by_price limit_by_conditions end def limit_by_category # Find object where the document category_ids contains the category_id we were given in params where_object[:category_ids] = [category_id] if category_id.present? end def limit_by_author where_object[:author_id] = author_id if author_id.present? end def limit_by_price if price_from.present? && price_to.present? where_object[:price] = (price_from..price_to) end if price_from.present? && price_to.blank? where_object[:price] = {gte: price_from} end if price_from.blank? && price_to.present? where_object[:price] = {lte: price_to} end end def limit_by_conditions where_object[:conditions] = conditions if conditions.present? end end
As we can see in our object, we have one run method that will set everything it needs itself and then return the Searchkick search results.
We can now do something like this to get results:
BookSearch.new(query: "Ruby", conditions: ["new"]).run
The beauty of this system is that it allows us to add more criteria easily in the future. It’s also very easy to test what each method is doing on its own, and it offers an object compatible with FormTag, SimpleForm and other form generators.
For example, a test file in Rspec may look like:
require 'rails_helper' RSpec.describe CaseSearch, search: true do let(:book) { create(:book) } context "methods" do describe ".query_string" do it "returns the query when present" do expect(CaseSearch.new(query: "Ruby").query_string).to eql("Ruby") end it "returns * when blank" do expect(CaseSearch.new(query: "").query_string).to eql("*") end end describe ".order" do it "order by published_at" do expect(CaseSearch.new(query: "nic").order).to eql({published_at: :desc}) end end describe ".limit_by_author_id" do it "limit the scope by author if passed" do search = CaseSearch.new(author_id: 1) search.limit_by_author_id expect(search.where_object).to match({author_id: 1}) end it "does not refine by author_id when no authors is passed" do search = CaseSearch.new search.limit_by_author_id expect(search.where_object).to_not have_key(:author_id) end end [...] end end
Now we have our search object, we can modify our controller like this:
class BooksController < ApplicationController def index options[:page] = params[:page] @search = CaseSearch.new(search_params.merge!(options)) @books = @search.run end def search_params params.require(:book_search).permit(:query, :category_id, :author_id, :price_from, :price_to, conditions: []) end end
And finally, wire up our views similar to this:
# app/views/books/_search_form.haml = simple_form_for @search, url: books_path, method: :get do |f| %h2 Filters .form-body = f.input :query, label: 'Keywords', placeholder: 'Keywords', as: 'search' = f.input :category_id, include_blank: "All categories", label: "Categories", collection: Category.all = f.input :author_id, label: 'Author', as: :select, collection: Author.all = f.input :price_from = f.inpur :price_to .footer = f.submit "Apply filters" = link_to "Clear filters", cases_path
Wrapping it up
By decoupling our search function, allowing it to behave like an ActiveRecord object and splitting every search criteria, we’ve created a very simple and extendable search for our app.
If tomorrow we wanted to add new criteria based on, say, the delivery time of our book, we will only have to continue extending our object by adding a new attribute to the BookSearch and a limit_by_delivery_type on our prepare_where_object method.
The ideal is for our code to always be readable, maintainable and scalable. We hope that sharing this search module will allow you to achieve the same for your app.
If you have more complex needs that you would like to discuss, why not drop us an email?