A Guide to Trailblazer Finders
And how to get the best out of them
Last time, we covered the basics of Trailblazer Operations in this previous story, and I strongly recommend checking it out before proceeding with this one, because it will be a continuation of the series.
What is a Finder?
Eventually, many of the features we have in our application start to require filtering and sorting functionalities, as a result, our controllers might start to grow in size, and they become nastier and clouded by the added code that takes care of the filtering mechanisms.
Trailblazer proposes a solution to the developer, which is to separate and extract these concepts into independent service objects — Finders — that encourage:
- Reusability: by isolating the logic to an exterior entity whose task is to only handle filtering, the dependency loosens allowing us to be able to implement the finder in other places.
- Cleanliness: we also increase the readability of the written code to higher levels, making it easy to understand for you and your team.
Requirements
We’re going to integrate a finder system into the band catalog API that we started building, to allow users to filter and navigate the data, so let’s go ahead and install the finder gem:
# Gemfile
gem 'trailblazer-finder'
Creating the finder
To follow Trailblazer conventions, we’ll need to nest our concept file under the directory of the subject of attention — in this case, it’s the Band
model — grouped by the nature of the concept which is finders
, then our class should inherit from Trailblazer::Finder
# app/concepts/band/finders/base.rb
module Band::Finders
class Base < Trailblazer::Finder
adapter :ActiveRecord
entity { Band }
end
end
Since we’re using Rails in this project, our ORM is ActiveRecord
by default, that’s why we need to specify which adapter to use by the finder; and if the block adapter
is forsaken, the finder will assume that it’s dealing with basic Hash
objects and not ActiveRecord
objects.
The block entity
signifies the default data model to apply filtering methods on, basically it’s the target.
Setting the entity is optional as you can determine what to choose to work with when you instantiate the finder object, I’ll explain more in detail when we try to implement the finder.
Filtering by name
# app/concepts/band/finders/base.rb
module Band::Finders
class Base < Trailblazer::Finder
adapter :ActiveRecord
entity { Band }
filter_by :name, with: :apply_name
def apply_name(entity, attribute, value)
entity.where('name ILIKE ?', "%#{value}%") if value.present?
end
end
end
filter_by
is used to describe a filtering rule, the first argument represents the name of the expected parameter, and the second argument is the name of the method that contains the logic.
Finder methods expect 3 arguments:
- entity: the targeted data source
- attribute: information about the current parameter
- value: the value of the parameter delivered by the request
Integrating the finder
The finder is finally ready to be used; so to implement it, we’ll edit the operation Index
, but here’s a reminder of how it last looked:
# app/concepts/band/operations/index.rb
module Band::Operations
class Index < Trailblazer::Operation
step :set_entity
def set_entity(context, **)
context[:data] = Band.order(formed_in: :desc)
end
end
end
Let’s change it to host the finder:
# app/concepts/band/operations/index.rb
module Band::Operations
class Index < Trailblazer::Operation
step :set_finder
def set_finder(context, params:, **)
context[:finder] = Band::Finders::Base.new(entity: Band.order(formed_in: :desc), params: params)
end
end
end
We made significant changes:
- We renamed
set_entity
toset_finder
set_finder
accepts a new argumentparams:
that refers to the parameters passed from the controller to the operation- we feed the
conext
object an instance of the finder that expects 2 things when initialized; the entity which is the list of bands ordered byformed_in
and the parameters to use when filtering.
Since our controller used to expect a data
object inside the context
, we’ll have to visit our controller to make a few changes as well:
# app/controllers/bands_controller.rb
class BandsController < ApplicationController
# GET /api/bands
def index
run Band::Operations::Index do |context|
render json: {
success: true,
data: context[:finder].result
}, status: :ok
end
end
...
end
Changed context[:data]
to context[:finder].result
, to retrieve the results of a Finder
, you need to access the result
property of the finder object.
Testing the new ‘index’ endpoint
Previously, we set up a fixtures file that contained 2 bands: Green Day
and Radiohead
, so with that in mind, we’ll create a test and attempt to search for bands that contain head
in their name, then we’ll assert that we got only 1 result which is the record related to Radiohead
# test/controllers/bands_controller_test.rb
require 'test_helper'
class BandsControllerTest < ActionDispatch::IntegrationTest
...
test 'GET #index: filter by name' do
get '/api/bands', params: {
name: 'head'
}
assert_response :success
assert response.parsed_body['success']
assert_equal 1, response.parsed_body['data'].count
assert_equal 'Radiohead', response.parsed_body['data'][0]['name']
end
end
Introducing pagination
To paginate your results, simply add paging
to your finder with specifying pagination settings like this:
- min_per_page: the allowed minimum number of results in one page
- max_per_page: the allowed maximum number of results in one page
- per_page: the default number of results in one page
# app/concepts/band/finders/base.rb
module Band::Finders
class Base < Trailblazer::Finder
adapter :ActiveRecord
entity { Band.order(formed_in: :desc) }
paging min_per_page: 1, max_per_page: 10, per_page: 2
filter_by :name, with: :apply_name
def apply_name(entity, attribute, value)
entity.where('name ILIKE ?', "%#{value}%") if value.present?
end
end
end
In the fixtures file, let’s add a few more records to test the new pagination system:
# test/fixtures/bands.yml
tool:
name: TOOL
genre:
- Progressive Metal
- Alt Metal
origin: California, United States
formed_in: 1990
pixies:
name: Pixies
genre:
- Alt Rock
origin: Massachusetts, United States
formed_in: 1986
Going back to the controller’s test:
The default paginator is simple as it only allows you to tweak the value of
per_page
, it does not let you choose the page nor it discloses any further information.
# test/controllers/bands_controller_test.rb
test 'GET #index: paginate' do
get '/api/bands', params: {
per_page: 4
}
assert_response :success
assert_equal 4, response.parsed_body['data'].count
end
To have more control and options, it’s best to use a gem that’s dedicated to pagination such as: kaminari
, will_paginate
Paginating using Kaminari
After installing the kaminari-activerecord
gem
# Gemfile
gem 'kaminari-activerecord'
Edit app/concepts/band/finders/base.rb
to determine which paginator
choice to use in our finder:
# app/concepts/band/finders/base.rb
module Band::Finders
class Base < Trailblazer::Finder
adapter :ActiveRecord
entity { Band.order(formed_in: :desc) }
paginator :Kaminari
paging min_per_page: 1, max_per_page: 10, per_page: 2
filter_by :name, with: :apply_name
def apply_name(entity, attribute, value)
entity.where('name ILIKE ?', "%#{value}%") if value.present?
end
end
end
The block paginator
allows us to select which tool to use for pagination.
This will bestow upon us many properties that we can use to help users navigate the catalog, so we’ll return a new object meta
in our action’s response in the controller:
# app/controllers/bands_controller.rb
# GET /api/bands
def index
run Band::Operations::Index do |context|
result = context[:finder].result
render json: {
success: true,
data: result,
meta: {
current_page: result.current_page,
next_page: result.next_page,
prev_page: result.prev_page,
total_pages: result.total_pages
}
}, status: :ok
end
end
The object meta
will describe the current settings of the final result, and due to kaminari
, the result object will give us access to many helpful pagination properties such as current_page
and total_pages
.
Testing the new pagination feature
In the latest test we wrote, we’ll add a new request but this time we’ll ask for 2 results in per_page
, and the 2nd page
; then we’ll assert the values of meta
to ensure we got the expected results knowing that we have 4 records in fixtures, so it should give a total of two pages containing 2 results for each one.
test 'GET #index: paginate' do
get '/api/bands', params: {
per_page: 4
}
assert_response :success
assert_equal 4, response.parsed_body['data'].count
get '/api/bands', params: {
per_page: 2,
page: 2
}
assert_response :success
assert_equal 2, response.parsed_body['data'].count
assert_equal 2, response.parsed_body['meta']['current_page']
assert_equal 2, response.parsed_body['meta']['total_pages']
end
Summary
We reached the end of this long tutorial that shines a light on how to integrate Trailblazer Finders.
But towards the end, especially the controller’s action index
, got long and deviated from the Single Responsibility Principle we preached and strived to uphold in these last couple of stories.
Not to mention, we return the data as it is, meaning we send all the properties of the model Band
including the timestamps; unsanitized, and unoptimized; so what if we only need to show a few attributes of the bands instead of all?
Well, do not worry, that’s a subject for another story! :)
Related Stories
PART 1: Why you should use Trailblazer
PART 2: A Guide to Trailblazer’s Operation
PART 3:🔹A Guide to Trailblazer’s Finder
PART 4: Everything you need to know about Trailblazer Representable