Benito Serna Tips and tools for Ruby on Rails developers

Simple Searchable module for searching with Rails and SQLite's LIKE

February 9, 2024

Yesterday, I found myself struggling with “litesearch”, and in my frustration, I decided to create a simple “Searchable” module to perform a “LIKE” search but with a cleaner interface/API.

If you’re looking for a straightforward way to implement a search feature in your application using “LIKE”, this post might be helpful to you.

…However, it turns out the issue wasn’t with “litesearch” at all. It was actually related to the parallelization of the specs. For now, I’ll continue using “litesearch” instead of the code provided in this post. Nevertheless, I wanted to share it because it might be useful to you (or even to myself in the future).

The Searchable Module

You can include the following code in app/models/concerns/searchable.rb:

module Searchable
  extend ActiveSupport::Concern

  included do
    cattr_accessor :searchable_fields, :searchable_associations_fields
  end

  class_methods do
    def search_on(*searchable_fields, **searchable_associations_fields)
      self.searchable_fields = searchable_fields
      self.searchable_associations_fields = searchable_associations_fields

      scope :search, ->(query) do
        joins(searchable_joins).where(searchable_where_clause, query: "%#{query}%")
      end

      scope :maybe_search, ->(query) do
        search(query) if query.present?
      end
    end

    def searchable_joins
      searchable_associations_fields.keys
    end

    def searchable_where_clause
      all_normalized_searchable_fields.map { |field| "#{field} LIKE :query" }.join(" OR ")
    end

    def all_normalized_searchable_fields
      normalized_searchable_fields + normalized_searchable_associations_fields
    end

    def normalized_searchable_fields
      searchable_fields.map { |field| [table_name, field].join(".") }
    end

    def normalized_searchable_associations_fields
      searchable_associations_fields.flat_map do |association, fields|
        table_name = reflect_on_association(association).klass.table_name
        Array.wrap(fields).map { |field| [table_name, field].join(".") }
      end
    end
  end
end

How to Use It

You can use the module in your models like this:

class Contact < ApplicationRecord
  include Searchable

  search_on :name, :last_name, :full_name, :email, :current_company, :phone
end

You can also search on associations using a hash key with the name of the association and a list of attributes to search on.

For example:

class QuoteRequest < ApplicationRecord
  include Searchable
  belongs_to :requester, class_name: "Contact"

  search_on :custom_id, :brand_name, :product_name, :more_info, :quantity, requester: :full_name
end

In this example, you’ll also search on the requester.full_name attribute, which corresponds to the "contacts.full_name" column.

If you want to search on multiple fields of the “requester”, you can do something like this:

class QuoteRequest < ApplicationRecord
  include Searchable
  belongs_to :requester, class_name: "Contact"

  search_on :custom_id, :brand_name, :product_name, :more_info, :quantity, requester: [:email, :full_name]
end

The tests to show how each part works

I didn’t write a lot of tests for it, and I didn’t write them in a generic way. However, I will show them to you because they can help you understand the purpose of each part of the code.

require "test_helper"

class SearchableTest < ActiveSupport::TestCase
  test "quote_requests metadata" do
    # Keeps the list of fields to search on the record
    assert_equal QuoteRequest.searchable_fields, [:custom_id, :brand_name, :product_name, :more_info, :quantity]

    # Keeps a hash with the list of fields to search for each association
    assert_equal QuoteRequest.searchable_associations_fields, {requester: :full_name}

    # Builds the list of fields with the table_name, to be used later in the query
    assert_equal QuoteRequest.normalized_searchable_fields, ["quote_requests.custom_id", "quote_requests.brand_name", "quote_requests.product_name", "quote_requests.more_info", "quote_requests.quantity"]

    # Builds the list of fields from the associations with the table_name, to be used later in the query
    assert_equal QuoteRequest.normalized_searchable_associations_fields, ["contacts.full_name"]

    # The string to be used in the where clause
    assert_equal QuoteRequest.searchable_where_clause, "quote_requests.custom_id LIKE :query OR quote_requests.brand_name LIKE :query OR quote_requests.product_name LIKE :query OR quote_requests.more_info LIKE :query OR quote_requests.quantity LIKE :query OR contacts.full_name LIKE :query"
  end

  test "contacts example" do
    result = Contact.search("Pedro")
    assert_equal result.count, 1
    assert_equal result.first, contacts(:one)

    result = Contact.search("Perez")
    assert_equal result.count, 1
    assert_equal result.first, contacts(:two)
  end

  test "quote_requests example" do
    result = QuoteRequest.search("Pedro")
    assert_equal 1, result.count
    assert_equal quote_requests(:one), result.first

    result = QuoteRequest.search("Perez")
    assert_equal 1, result.count
    assert_equal quote_requests(:two), result.first
  end
end

Maybe you can use it

While I won’t be using this module for now due to my resolution of the issue with “litesearch”, perhaps you may find it useful.

Whether you’re interested in adapting it for PostgreSQL or exploring alternative search solutions, I hope this code may serve as a helpful reference or source of inspiration.

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.