A Guide to Trailblazer Finders

And how to get the best out of them

Ibrahim Areda
6 min readJan 15, 2024

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 to set_finder
  • set_finder accepts a new argument params: 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 by formed_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

--

--

Ibrahim Areda

💎 A Rails Developer - I write about programming tutorials.