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
endIn 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'
endSince 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 nowYou 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)
endHit 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
endView 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
endConvert 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
endCreate the SerializablePositionSearchResult class that we referenced
in app/serializers/serializable_employee.rb:
class SerializablePositionSearchResult < JSONAPI::Serializable::Resource
type :positions
attribute :title
endWe 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.