Usage Without ActiveRecord: ElasticSearch

Though we’ll be hitting elasticsearch in this example, remember that this is just an HTTP API underneath the hood. The same pattern applies to a variety of use cases.

First we need a Client for elasticsearch. Though you can feel free to use a variety of clients, this example will use trample.

Though we’ll show code snippets below, feel free to view the diff on github.

Also keep in mind, we’ll be showing a one-off customization here. You probably want to extract this code into an Adapter if this is going to become a core component of your application.

Start by installing trample:

# Gemfile
gem 'trample_search', require: 'trample'

Tell searchkick that we want to index Employees and Positions:

# app/models/employee.rb
searchkick text_start: [:first_name]

# app/models/position.rb
searchkick text_start: [:title]

Define our search classes. These tell trample the configuration of the search:

# app/models/employee_search.rb
class EmployeeSearch < Trample::Search
  model Employee

  condition :first_name, single: true
  condition :last_name, single: true
end

# app/models/position_search.rb
class PositionSearch < Trample::Search
  model Position

  condition :title, single: true
  condition :employee_id
end

In our controller, we need to pass a base scope. Before, we were passing an ActiveRecord::Relation (Post.all). Let’s pass an instance of Trample::Search instead. Since by default search results come back as Hashie::Mashes, we’ll also specify our serializer directly. You could also use a generic SearchResult serializer, it’s up to you.

# app/controllers/employees_controller.rb
def index
  render_jsonapi EmployeeSearch.new,
    class: SerializableEmployeeSearchResult
end
# app/serializers/serializable_employee_search_result.rb
class SerializableEmployeeSearchResult < JSONAPI::Serializable::Resource
  type :employees

  attribute :first_name
  attribute :last_name
  attribute :created_at
  attribute :updated_at

  has_many :positions, class: 'SerializablePositionSearchResult'
end

Since we are now passing a non-default base scope, we need to tell our Resource how to query and resolve this new scope. Start by switching to the pass-through adapter, and resolve using trample’s query API:

# app/resources/employee_resource.rb
use_adapter JsonapiCompliable::Adapters::Null
# ... code ...
def resolve(scope)
  scope.query!
  scope.results
end

# remove the belongs_to for now

You can now hit http://localhost:3000/api/v1/employees - the exact same payload is coming back, but is now sourced from elasticsearch!

Let’s add a prefix filter:

# app/resources/employee_resource.rb
allow_filter :first_name_prefix do |scope, value|
  scope.condition(:first_name).starts_with(value)
end

Hit http://localhost:3000/api/v1/employees?filter[first_name]=hom. You’re now successfully querying the elasticsearch index.

If we want sorting and pagination, we need to tell the Resource how to deal with that, too:

# app/resources/employee_resource.rb
paginate do |scope, current_page, per_page|
  scope.metadata.pagination.current_page = current_page
  scope.metadata.pagination.per_page = per_page
  scope
end

sort do |scope, att, dir|
  scope.metadata.sort = [{att: att, dir: dir}]
  scope
end

View the Resource and Adapter documentation for additional overrides, like statistics.

The last step is adding the positions association. If we want has_many-style macros we need to create an Adapter, but for now let’s simply use the lower-level allow_sideload DSL. We need to define two functions: how to build a scope for the association, and how to associate the resulting objects:

# app/resources/employee_resource.rb
allow_sideload :positions, resource: PositionResource do
  scope do |employees|
    scope = PositionSearch.new
    scope.condition(:employee_id).or(employees.map(&:id))
  end

  assign do |employees, positions|
    employees.each do |e|
      e.positions = positions.select { |p| p.employee_id = e.id }
    end
  end
end

Convert the PositionResource to use elasticsearch, just like we did for Employee:

# app/resources/position_resource.rb
use_adapter JsonapiCompliable::Adapters::Null

def resolve(scope)
  scope.query!
  scope.results
end

Create the SerializablePositionSearchResult class that we referenced in app/serializers/serializable_employee.rb:

class SerializablePositionSearchResult < JSONAPI::Serializable::Resource
  type :positions

  attribute :title
end

We can now sideload positions - check out the results at http://localhost:3000/api/v1/employees?include=positions. We’re fetching employees and their corresponding positions in a single request, via elasticsearch. Any filters/changes/default sort/etc that apply to PositionResource can be re-used at this endpoint.

If this was a one-off section of our application, we can call this good enough and move on. But as we continue to use this pattern, it’s going to get monotonous writing the same filter overrides, allow_sideload wiring code, etc. To DRY up this code, we can package our changes into an Adapter.