« Home

Full-text search with JSONAPI Resources

PostgreSQL has a decent full-text search facility built into it that is more than adequate for many use cases. This article shows how to use it along with JSONAPI Resources in a Rails app. I urge you to read the PostgreSQL documentation, especially chapters 12.1, 12.2, and 12.3, before continuing with this article so you have good understanding of the fundamentals of full-text search.

Let’s say we want to search the users table on first_name, last_name, or both. We can use a filter in UserResource and apply a callable to it that actually performs the search.

1. Create failing test, empty method

First we need to set up a test. We want to test that it returns the records we expect when we search with a first name, a last name, and both first and last name.

# spec/resources/user_resource_spec.rb
require 'rails_helper'

describe UserResource do
  describe "search filter" do
    let!(:u1)  { User.create(first_name: "Joe", last_name: "Brown") }
    let!(:u2)  { User.create(first_name: "Jane", last_name: "Brown") }
    let!(:u3)  { User.create(first_name: "Joe", last_name: "Jones") }

    let(:options) { {} }

    it "searches on first name" do
      records = described_class.find({ search: "joe" }, options).map(&:_model)
      expect(records).to contain_exactly(u1, u3)
    end

    it "searches on last name" do
      records = described_class.find({ search: "brown" }, options).map(&:_model)
      expect(records).to contain_exactly(u1, u2)
    end

    it "ANDs search terms" do
      records = described_class.find({ search: "joe jones"}, options).map(&:_model)
      expect(records).to contain_exactly(u3)
    end
  end
end

We also need to add the filter to the resource. We can call the filter :search:

class UserResource < JSONAPI::Resource
  attributes  :first_name, :last_name

  filter :search, apply: ->(records, value, _options) {
    [] # return an empty array until database is set up.
  }

end

When we run the spec, it should fail (as opposed to blow up).

2. Set up database for full-text search

There are a number of ways to use full-text search in PostgreSQL, but the most efficient for our use case is to add a separate column to the users table to store the tsvector representation of first_name and last_name, and add a GIN index to speed up the search. In order to make sure the index reflects the current state of first_name and last_name, we have to add a trigger to the database which will automatically update the index whenever a new row is added to users or the value of first_name or last_name are updated on an existing row.

Note that schema.rb does not know about triggers, so we have to build a structure.sql schema file instead. To do this, modify config.application.rb with the following:

config.active_record.schema_format = :sql

Delete schema.rb:

rm db/schema.rb

We can now generate a migration and edit it to add the column, index, and trigger:

rails g migration add_tsvector_column_to_users
class AddTsvectorColumnToUsers < ActiveRecord::Migration[5.1]
  def up
    execute <<-SQL
      ALTER TABLE users ADD COLUMN ts_names tsvector;
      UPDATE users SET ts_names =
        to_tsvector('english', coalesce(first_name,'') || ' ' || coalesce(last_name, ''));
      CREATE INDEX ts_names_idx ON users USING GIN (ts_names);
      CREATE TRIGGER ts_names_update BEFORE INSERT OR UPDATE
        ON users FOR EACH ROW EXECUTE PROCEDURE
        tsvector_update_trigger(ts_names, 'pg_catalog.english', first_name, last_name);
    SQL
  end

  def down
    execute <<-SQL
      DROP TRIGGER [ IF EXISTS ] ts_names_update ON users;
      DROP INDEX [ IF EXISTS ] ts_names_idx;
      ALTER TABLE users DROP COLUMN [ IF EXISTS ] ts_names;
    SQL
  end
end

3. Add search to the filter

Modifying the filter to use search is straightforward:

class UserResource < JSONAPI::Resource
  attributes  :first_name, :last_name

  filter :search, apply: ->(records, value, _options) {
    records.where("ts_names @@ plainto_tsquery(:query)", query: value)
  }

end

When we run our spec again the tests should succeed.

The rails app can now correctly handle requests like this:

http://example.com/users?filter[search]=joe%40jones

You get pagination without any additional work:

http://example.com/users?filter[search]=jones&page%5Bnumber%5D=2&page%5Bsize%5D=10