Ruby
Article

Liberate Your Search in Rails with Tags

By Vinoth

Search is an essential part of any website, even more so for a content-centric website. A good search system is a fast one that provides accurate results. Search and filter are mostly the same depending on how you look at it. For this article, I’ll treat search and filter as the same kind of function.

There are many existing methods to implement search from the tools we use like PostgreSQL full text search, which you can use in Rails. Today, however, we’ll see how to implement tag-based filtering in an Rails app using PostgreSQL.

Let’s get started.

About the Implementation

Let’s see how the tag based implementation works. Assume we have a table called companies and it has the following relation association chain:

companies -> BELONGS_TO -> cities -> BELONGS_TO -> states -> BELONGS_TO -> countries

The companies table has columns such as yearly_revenue, employee_strength, started_year, etc.

Say we have a page for companies and on that page are filters. We now want to filter all the companies that are in a certain country. We could achieve this by joining the tables and filtering based on the country ID provided, easy enough. But what about scenarios where the filters are combined and get more complicated?

For example, we want the following filter:

Companies that are in New York and started before 2001 and have revenue of more than $200 million and employ more than 1000 people.

It’s still achievable, but the query gets extended and, in all likelihood, a bit uglier. The real problem is facilitating the filter options for the users. One way is putting relevant elements (text boxes and selects) for each filter we allow the user to perform, which becomes a problem when we want to include more and more filters. IF we want to do 50 different kinds of filters on the information, it won’t be a pretty sight using one element per filter. Also, on the query part, consider that there are relations from companies going in 10 different directions, each nesting its own set of associations? I am sure you can see how fast this gets ugly. This is where a tagging solution is a much better answer.

In a tag-based implementation, we basically add a tag column with an array datatype to the table in which we want to perform the filtering. In our case, this is the companies table. The column consists of an array of Universally Unique Identifiers (UUIDs) which correspond to the IDs of tags to which the record relates. We’ll have a separate table called tags consisting of the tag id, name, and type. Now, the search and filter is as easy as searching on the tags table, finding the UUIDs, and then filtering the companies based on those tag IDs.

Let’s implement this with help of an example.

Example Rails App

Our Rails app will have five tables

  • companies
  • cities
  • states
  • countries
  • tags

Begin by creating the Rails app along with the necessary models:

rails new tag-example -d postgresql

For the tag implementation to work best, we’ll stick to using a UUID as the primary key in all the tables, so let’s create a migration to enable PostgreSQL’s uuid-ossp extension:

rails g migration enable_uuid_ossp

After generating the migration file, add the following command to the file:

enable_extension "uuid-ossp"

Run the migration:

rake db:create && rake db:migrate

Create our models, starting with companies:

rails g scaffold companies name founding_year:integer city_id:uuid

As I mentioned above, we need to maintain the id field as a UUID for the tables. Head over to the create_companies migration file and modify the create_table line to specify that the id column should be a uuid. PostgreSQL will take care of auto-generating the UUIDs. Also, add a line to add a tags column to the companies table, since including it in a scaffold will make it display to the user.

create_table :companies, id: :uuid do |t|
    t.string :tags, array: true, default: []

Next up, create the cities, states, and countries models, each making the ID a UUID, as mentioned above, by modifying the migrations:

rails g model cities name state_id:uuid
rails g model states name country_id:uuid
rails g model countries name
rails g model tags name tag_type

After you run the migrations, quickly establish the relations in the model files:

## company.rb
belongs_to :city

## city.rb
has_many :companies
belongs_to :state

## state.rb
has_many :cities
belongs_to :country

## country.rb
has_many :states

The example will be clearer if we generate some seed data. For this exercise, I’m using the faker gem to generate the company, city, and state information. Here is the seed (/db/seed.rb) file I’ve used, you can use this for reference;

(1..10).each do |i|
  country = Country.create(name: Faker::Address.country)
  (1..10).each do |j|
    state = State.create(name: Faker::Address.state, country: country)
    (1..10).each do |k|
      city = City.create(name: Faker::Address.city, state: state)
    end
  end
end

City.all.each do |city|
  (1..10).each do |count|
    company_name = Faker::Company.name
    Company.create(name: company_name, founding_year: rand(1950..2015), city: city)
    p "Saved - #{company_name} - #{count}"
  end
end

Now, let’s add our before_save callback to the companies model which will generate the tags for the company every time it’s saved. This takes care of creating the entry in the tags table, too. In models/company.rb add the following code:

class Company < ActiveRecord::Base
  belongs_to :city

  before_save :update_tags

  def save_location_tags
    [city, city.state, city.state.country].map do |loc|
      (Tag.it({id: loc.id, name: loc.name, tag_type: 'LOCATION'}).id rescue nil)
    end
  end

  def update_tags
    self.tags = [
      save_location_tags,
      (Tag.it({name: founding_year, tag_type: 'COMPANY_FOUNDING_YEAR'}).id rescue nil)
    ].flatten.compact
  end
end

The above code actually re-generates and updates the company tags every time it’s saved. In apps/models/tag.rb, add the following:

class Tag < ActiveRecord::Base
  def self.it content
    Tag.where(content).first_or_create! rescue nil
  end
end

You can add as many tags as you want to the update_tag method. We’re all set from the data update standpoint. Let’s now implement the tag search and filter.

Tag Filter

We’ll have a page to display the list of companies and have an autocomplete on the input for the tag names. Also, the code will invoke the filter every time the user makes a selection, displaying the currently selected filters as we go.

In app/controllers/companies_controller.rb add the lines below to the index action:

def index
  if params[:tags]
    @companies = Company.tagged(params[:tags])
  else
    @companies = Company.all.limit(10)
  end
end

In app/models/company.rb, add the tagged scope as follows:

scope :tagged, -> (tags) {where('companies.tags @> ARRAY[?]::varchar[]', [tags].flatten.compact)}

This query accepts an array of tags and filters all the records where the given array is present in its tags column.

We’re now going to use the pg_trgm extension to create the autocomplete for tags. Just like the other extension, we’ll need a migration and enable it:

enable extension "pg_trgm"

Let’s create a tags controller and add an autocomplete endpoint to it, which we’ll make use of from the front-end to get the matching tag IDs for user queries:

rails g controller tags autocomplete

Add the following code to the autocomplete method in tags controller:

def autocomplete
  results = Tag.select("*, string <-> #{ActiveRecord::Base.sanitize(params[:q])} as distance").order('distance').limit(5)
  render json: results
end

Now we have a complete working set of tag-based filter endpoints and tag autocomplete endpoints. You can check them out by starting the Rails server (rails s) and trying the below endpoints:

  • Tag autocomplete example: http://localhost:3000/tags/autocomplete?q=port
  • Filtered companies based on tag example: http://localhost:3000/companies?tags[]=ANYUUIDFROMTAGSTABLE

I’ll leave you to implement the autocomplete in the front-end, since there are many ways and many good tutorials out there on how to achieve this. Combine it with our above filter set and you’ll have a powerful filtering system for your data.

Conclusion

With that we have come to the conclusion of our tutorial. We now have a powerful and efficient filtering and search system readily available to be integrated to any number of tables. It is easy to use it in a new project or to integrate into an existing one.

All the code shown in the example is available in github.

Thanks for reading through and I hope you’ve learned something today.

  • Alberto

    I also would be interested in a replay about this consideration.

Recommended
Sponsors
Get the latest in Ruby, once a week, for free.