In this chapter, we are going to build the first query builder. While doing this, we’ll run into a few things that are missing, so we’ll implement those along the way.
Query builders are used to scope down a list of entities based on the parameters requested by the client. In Alexandria, we want to provide 3 ways for the client to do that: pagination, sorting and filtering.
First, pagination will allow the client to only get a subset of the results using two parameters: page
and per
. Sorting will let a client decide how the list of entities should be organized, following the identifier, the released date or something else. Finally, filtering will be used to only get a subset of entities that are relevant to the given parameters.
To create the pagination query builder, we don’t need to update our presenters. Since we will be using global default values for the page
and per
parameters, there is no need to define those values in the presenters.
Currently, if we ask for the list of books, we get the entire list back. That’s fine for now because we only have 3 books, but for 3000 it won’t be okay anymore.
To fix that, we are going to build the pagination query builder that will rely on a neat little gem known as Kaminari. Just like will_paginate, this gem takes care of paginating a list of records.
Let’s add Kaminari
to the Gemfile
…
# Gemfile
source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '2.5.0'
gem 'rails', '5.2.0'
gem 'sqlite3'
gem 'puma', '~> 3.11'
gem 'bootsnap', '>= 1.1.0', require: false
gem 'carrierwave'
gem 'carrierwave-base64'
# Add the kaminari gem
gem 'kaminari'
group :development, :test # Hidden Code
group :development # Hidden Code
group :test # Hidden Code
and run bundle install
to get it installed.
Next, we need to create a bunch of folders for the query builders.
mkdir app/query_builders && mkdir spec/query_builders
Now, add the files for the Paginator
builder and its tests with the command below.
touch app/query_builders/paginator.rb && \
touch spec/query_builders/paginator_spec.rb
In the paginator.rb
file we just created, put the code below. It’s the skeleton of the class we are about to build.
# app/query_builders/paginator.rb
class Paginator
def initialize(scope, params, url)
end
end
Before we implement the rest, let’s write a bunch of tests to define the expectations of the Paginator
. Once instantiated, this class will have the method paginate
, which will return the paginated scope we passed in. To be able to write tests for it though, we first need to define some variables.
All these variables does is create a list of 3 books (the same as before) for us to play with. The other let
calls are there to instantiate the Paginator
class.
We don’t extensively test the pagination itself because we rely on the tests implemented in Kaminari
itself to ensure that it works correctly. Here we just want to ensure that, when given specific parameters, we get the corresponding subset of entities.
# spec/query_builders/paginator_spec.rb
require 'rails_helper'
RSpec.describe Paginator do
let(:ruby_microscope) { create(:ruby_microscope) }
let(:rails_tutorial) { create(:ruby_on_rails_tutorial) }
let(:agile_web_dev) { create(:agile_web_development) }
let(:books) { [ruby_microscope, rails_tutorial, agile_web_dev] }
let(:scope) { Book.all }
let(:params) { { 'page' => '1', 'per' => '2' } }
let(:paginator) { Paginator.new(scope, params, 'url') }
let(:paginated) { paginator.paginate }
before do
books
end
describe '#paginate' do
it 'paginates the collection with 2 books' do
expect(paginated.size).to eq 2
end
it 'contains ruby_microscope as the first paginated item' do
expect(paginated.first).to eq ruby_microscope
end
it 'contains rails_tutorial as the last paginated item' do
expect(paginated.last).to eq rails_tutorial
end
end
end
Try to run the tests.
rspec spec/query_builders/paginator_spec.rb
Of course it fails, since we haven’t implemented the code yet.
Failure (RED)
...
Finished in 0.24135 seconds (files took 2.23 seconds to load)
3 examples, 3 failures
...
We can fix it by adding a bunch of code to the Paginator
class. We need to ensure that the paginate
method returns the paginated scope. To do so, we need to extract the page
and per
parameters from the query parameters. Don’t pay too much attention to the url
parameter, we will be using it to generate some metadata in the next section.
# app/query_builders/paginator.rb
class Paginator
def initialize(scope, query_params, url)
@query_params = query_params
@page = @query_params['page'] || 1
@per = @query_params['per'] || 10
@scope = scope
@url = url
end
def paginate
@scope.page(@page).per(@per)
end
end
Let’s run the tests.
rspec spec/query_builders/paginator_spec.rb
Success (GREEN)
...
Paginator
#paginate
paginates the collection with 2 books
contains ruby_microscope as the first paginated item
contains rails_tutorial as the last paginated item
Finished in 0.30235 seconds (files took 2.18 seconds to load)
3 examples, 0 failures
Yes, great! However, we need to provide something else. We need to give more information to the client about the pagination and how to access the other pages.
Instead of letting the client figure out everything on its own, we can provide it with a few useful links to go through the paginated collection. For example, things like the first page, previous page, next page and last page would be very useful. This kind of metadata is usually added somewhere in the JSON
document and then sent back, so we could do it that way. But an emerging best practice is actually to put those links in the Link
header, so we’ll do that now.
Let’s add more tests to the paginator_spec
file. We need to ensure that the links
method in the Paginator
class returns a string containing the first/previous/next/last URLs formatted in the correct way:
<http://localhost:4567/api/books?page=X&per=Y>; rel="first|previous|next|last"
# spec/query_builders/paginator_spec.rb
require 'rails_helper'
RSpec.describe Paginator do
# Hidden Code: let definitions
describe '#paginate' # Hidden Code
describe '#links' do
let(:links) { paginator.links.split(', ') }
context 'when first page' do
let(:params) { { 'page' => '1', 'per' => '2' } }
it 'builds the "next" relation link' do
expect(links.first).to eq '<url?page=2&per=2>; rel="next"'
end
it 'builds the "last" relation link' do
expect(links.last).to eq '<url?page=2&per=2>; rel="last"'
end
end
context 'when last page' do
let(:params) { { 'page' => '2', 'per' => '2' } }
it 'builds the "first" relation link' do
expect(links.first).to eq '<url?page=1&per=2>; rel="first"'
end
it 'builds the "previous" relation link' do
expect(links.last).to eq '<url?page=1&per=2>; rel="prev"'
end
end
end # describe '#links' end
end
If we run the tests for this file, we get a bunch of failures.
rspec spec/query_builders/paginator_spec.rb
Failure (RED)
...
Finished in 0.39977 seconds (files took 2.2 seconds to load)
7 examples, 4 failures
...
Let’s add the missing code to the Paginator
to make all those tests pass. Since there are many additions, going through each one of them before looking at the whole class should be easier.
The first thing we need is a few methods to check which links should be included. A client asking for the first page (?page=1&per=Y
) doesn’t really care about the pagination link of the first page or the previous page (there are no previous pages!). However, the client would be interested in getting the next page and the last one.
def show_first_link?
@scope.total_pages > 1 && !@scope.first_page?
end
def show_previous_link?
!@scope.first_page?
end
def show_next_link?
!@scope.last_page?
end
def show_last_link?
@scope.total_pages > 1 && !@scope.last_page?
end
Next, we need a method that’s going to build a hash with each link. This method will be the only one using the checkers we created above.
def pages
@pages ||= {}.tap do |h|
h[:first] = 1 if show_first_link?
h[:prev] = @scope.current_page - 1 if show_previous_link?
h[:next] = @scope.current_page + 1 if show_next_link?
h[:last] = @scope.total_pages if show_last_link?
end
end
Finally, the links
method will be a public method. This method will be called on an instantiated Paginator
at the controller level to set the Link
header.
def links
@links ||= pages.each_with_object([]) do |(k, v), links|
query_params = @query_params.merge({ 'page' => v, 'per' => @per }).to_param
links << "<#{@url}?#{query_params}>; rel=\"#{k}\""
end.join(', ')
end
Here is the complete updated class.
# app/query_builders/paginator.rb
class Paginator
def initialize(scope, query_params, url)
@query_params = query_params
@page = @query_params['page'] || 1
@per = @query_params['per'] || 10
@scope = scope.page(@page).per(@per)
@url = url
end
def paginate
@scope
end
def links
@links ||= pages.each_with_object([]) do |(k, v), links|
query_params = @query_params.merge({ 'page' => v, 'per' => @per }).to_param
links << "<#{@url}?#{query_params}>; rel=\"#{k}\""
end.join(", ")
end
private
def pages
@pages ||= {}.tap do |h|
h[:first] = 1 if show_first_link?
h[:prev] = @scope.current_page - 1 if show_previous_link?
h[:next] = @scope.current_page + 1 if show_next_link?
h[:last] = @scope.total_pages if show_last_link?
end
end
def show_first_link?
@scope.total_pages > 1 && !@scope.first_page?
end
def show_previous_link?
!@scope.first_page?
end
def show_next_link?
!@scope.last_page?
end
def show_last_link?
@scope.total_pages > 1 && !@scope.last_page?
end
end
If we run the tests now, everything should work properly!
rspec spec/query_builders/paginator_spec.rb
Success (GREEN)
...
Paginator
#paginate
paginates the collection with 2 books
contains ruby_microscope as the first paginated item
contains rails_tutorial as the last paginated item
#links
when first page
builds the "next" relation link
builds the "last" relation link
when last page
builds the "first" relation link
builds the "previous" relation link
Finished in 0.69567 seconds (files took 2.73 seconds to load)
7 examples, 0 failures
The Paginator
seems to be ready. To make it easier to reuse it across different controllers, we are going to add a method in the ApplicationController
that will deal with instantiating a paginator and setting the Link
header.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
protected
def paginate(scope)
paginator = Paginator.new(scope, request.query_parameters, current_url)
response.headers['Link'] = paginator.links
paginator.paginate
end
def current_url
request.base_url + request.path
end
end
Before we update the books
controller, we can write more tests in the spec/requests/books_spec.rb
file to test the pagination. In those tests, we need to check if we are correctly getting the paginated subset of entities and if the Link
header is correctly set depending on the page we requested.
# spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
# Hidden Code: let definitions
describe 'GET /api/books' do
before { books }
context 'default behavior' # Hidden Code
describe 'field picking' # Hidden Code
describe 'pagination' do
context 'when asking for the first page' do
before { get('/api/books?page=1&per=2') }
it 'receives HTTP status 200' do
expect(response.status).to eq 200
end
it 'receives only two books' do
expect(json_body['data'].size).to eq 2
end
it 'receives a response with the Link header' do
expect(response.headers['Link'].split(', ').first).to eq(
'<http://www.example.com/api/books?page=2&per=2>; rel="next"'
)
end
end
context 'when asking for the second page' do
before { get('/api/books?page=2&per=2') }
it 'receives HTTP status 200' do
expect(response.status).to eq 200
end
it 'receives only one book' do
expect(json_body['data'].size).to eq 1
end
end
end # describe 'pagination' end
end
end
Try to run the tests… then watch them fail miserably
rspec spec/requests/books_spec.rb
Failure (RED)
...
Finished in 0.61217 seconds (files took 2.16 seconds to load)
10 examples, 3 failures
...
Luckily, we can easily fix those tests by simply calling the paginate
method (paginate(Book.all)
) in the books controller.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
books = paginate(Book.all).map do |book|
FieldPicker.new(BookPresenter.new(book, params)).pick
end
render json: { data: books }.to_json
end
end
Let’s try running the tests again.
rspec spec/requests/books_spec.rb
Success (GREEN)
...
Books
GET /api/books
gets HTTP status 200
receives a json with the "data" root key
receives all 3 books
field picking
with the fields parameter
gets books with only the id, title and author_id keys
without the field parameter
gets books with all the fields specified in the presenter
pagination
when asking for the first page
receives HTTP status 200
receives only two books
receives a response with the Link header
when asking for the second page
receives HTTP status 200
receives only one book
Finished in 1.12 seconds (files took 1.81 seconds to load)
10 examples, 0 failures
Success, yay!
What if a client starts sending incorrect values for the page
or per
parameters? For instance using a word instead of a number? We cannot let that happen.
In those situations, we should send back 400 Bad Request
because the client made a mistake when building the request and we cannot process it. Add a small test to ensure that we get 400
when calling /api/books?page=fake&per=10
.
# spec/requests/books_spec.rb
require "rails_helper"
RSpec.describe "Books", type: :request do
# Hidden Code: let definitions
describe 'GET /api/books' do
before { books }
context 'default behavior' # Hidden Code
describe 'field picking' # Hidden Code
describe 'pagination' do
context 'when asking for the first page' # Hidden Code
context 'when asking for the second page' # Hidden Code
context "when sending invalid 'page' and 'per' parameters" do
before { get('/api/books?page=fake&per=10') }
it 'receives HTTP status 400' do
expect(response.status).to eq 400
end
end
end # describe 'pagination' end
end
end
rspec spec/requests/books_spec.rb
Failure (RED)
1) Books GET /api/books pagination when sending invalid 'page' and 'per'
parameters receives HTTP status 400
Failure/Error: expect(response.status).to eq 400
expected: 400
got: 200
...
Finished in 0.78048 seconds (files took 2.22 seconds to load)
11 examples, 1 failure
Of course the new test is failing. We are currently not checking if the parameters are valid. To fix this test, we are going to create our first custom error, QueryBuilderError
.
Let’s store our custom errors in the app/errors
folder.
mkdir app/errors && touch app/errors/query_builder_error.rb
Here is the content of this new class that inherits from StandardError
.
# app/errors/query_builder_error.rb
class QueryBuilderError < StandardError
attr_accessor :invalid_params
def initialize(invalid_params)
@invalid_params = invalid_params
super
end
end
Next, we need a method in the Paginator
class to ensure that the given query parameters are valid. That’s the responsibility of the validate_param
method that will take two arguments: the parameter being checked (page
or per
) and the default value for that specific parameter. It will check if the value sent by the client is a number using a simple regular expression and raise the QueryBuilderError
error if that’s not the case.
# app/query_builders/paginator.rb
class Paginator
def initialize(scope, query_params, url)
@query_params = query_params
@page = validate_param!('page', 1)
@per = validate_param!('per', 10)
@scope = scope.page(@page).per(@per)
@url = url
end
def paginate # Hidden Code
def links # Hidden Code
private
def validate_param!(name, default)
return default unless @query_params[name]
unless (@query_params[name] =~ /\A\d+\z/)
raise QueryBuilderError.new("#{name}=#{@query_params[name]}"),
'Invalid Pagination params. Only numbers are supported for "page" and "per".'
end
@query_params[name]
end
def pages # Hidden Code
def show_first_link? # Hidden Code
def show_previous_link? # Hidden Code
def show_next_link? # Hidden Code
def show_last_link? # Hidden Code
end
How is our test doing now?
rspec spec/requests/books_spec.rb
Failure (RED)
1) Books GET /api/books pagination when sending invalid "page" and "per"
parameters receives HTTP status 400
Failure/Error: before { get('/api/books?page=fake&per=10') }
QueryBuilderError:
Invalid Pagination params. Only integers supported for 'page' and 'per'.
It’s correctly raising the error, but this is not good… It’s just going to tell the client that something went wrong in the API without many details. We should return a JSON
document explaining the problem.
To be able to return a JSON
document when the error is raised, we need to catch it at the controller level. The good news is that it’s pretty simple using something like this:
rescue_from QueryBuilderError, with: :handling_method
Let’s add this line to the ApplicationController
with a handling method named query_builder_error
that will return a response containing the error message and the invalid parameters.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from QueryBuilderError, with: :query_builder_error
protected
def query_builder_error(error)
render status: 400, json: {
error: {
message: error.message,
invalid_params: error.invalid_params
}
}
end
def paginate(scope)
paginator = Paginator.new(scope, request.query_parameters, current_url)
response.headers['Link'] = paginator.links
paginator.paginate
end
def current_url
request.base_url + request.path
end
end
Let’s run our tests to see if we are correctly getting 400 Bad Request
now.
rspec spec/requests/books_spec.rb
Success (GREEN)
...
Finished in 0.94986 seconds (files took 2.68 seconds to load)
11 examples, 0 failures
Before we proceed, it’s probably a good idea to add a few more tests checking the error document being returned.
# spec/requests/books_spec.rb
require "rails_helper"
RSpec.describe "Books", type: :request do
# Hidden Code: let definitions
describe 'GET /api/books' do
before { books }
context 'default behavior' # Hidden Code
describe 'field picking' # Hidden Code
describe 'pagination' do
context 'when asking for the first page' # Hidden Code
context 'when asking for the second page' # Hidden Code
context "when sending invalid 'page' and 'per' parameters" do
before { get('/api/books?page=fake&per=10') }
it 'receives HTTP status 400' do
expect(response.status).to eq 400
end
it 'receives an error' do
expect(json_body['error']).to_not be nil
end
it "receives 'page=fake' as an invalid param" do
expect(json_body['error']['invalid_params']).to eq 'page=fake'
end
end
end # describe 'pagination' end
end
end
rspec spec/requests/books_spec.rb
...
Finished in 0.85853 seconds (files took 2.93 seconds to load)
13 examples, 0 failures
We are now handling errors and will be able to reuse this system for the other query builders.
We didn’t have to change our presenter for the pagination. In order to implement sorting and filtering, though, we need a place for each model to define which fields can be used for those two features.
BasePresenter
ClassJust like for the field picking feature, we need a way in the presenters to define a list of attributes that can be used. First, let’s write some tests! They are going to be super similar to the one we wrote for the build_with
class method because they will work in exactly the same way.
# spec/presenters/base_presenter_spec.rb
require 'rails_helper'
RSpec.describe BasePresenter do
class Presenter < BasePresenter; end
describe '#initialize' # Hidden code
describe '#as_json' # Hidden code
describe '.build_with' # Hidden Code
describe '.related_to' do
it 'stores the correct value' do
Presenter.related_to :author, :publisher
expect(Presenter.relations).to eq ['author', 'publisher']
end
end
describe '.sort_by' do
it 'stores the correct value' do
Presenter.sort_by :id, :title
expect(Presenter.sort_attributes).to eq ['id', 'title']
end
end
describe '.filter_by' do
it 'stores the correct value' do
Presenter.filter_by :title
expect(Presenter.filter_attributes).to eq ['title']
end
end
end
First step of the TDD cycle: run the tests.
rspec spec/presenters/base_presenter_spec.rb
Failure (RED)
...
Finished in 0.06466 seconds (files took 2.17 seconds to load)
8 examples, 3 failures
...
To make those tests pass, let’s implement the same logic used for build_attributes
. This time, the new attributes are sort_attributes
, filter_attributes
and relations
. relations
will be used later for the representation builder that will take care of embedding other entities, but we can already add it.
# app/presenters/base_presenter.rb
class BasePresenter
# Add more class instance attributes
@relations = []
@sort_attributes = []
@filter_attributes = []
@build_attributes = []
class << self
# Define the accessors for the attributes created
# above
attr_accessor :relations, :sort_attributes,
:filter_attributes, :build_attributes
def build_with(*args)
@build_attributes = args.map(&:to_s)
end
# Add a bunch of methods that will be used in the
# model presenters
def related_to(*args)
@relations = args.map(&:to_s)
end
def sort_by(*args)
@sort_attributes = args.map(&:to_s)
end
def filter_by(*args)
@filter_attributes = args.map(&:to_s)
end
end
# Hidden Code
# accessors, initialize, ...
end
If we run the tests now, they are passing - awesome!
rspec spec/presenters/base_presenter_spec.rb
Success (GREEN)
...
Finished in 0.06283 seconds (files took 2.24 seconds to load)
8 examples, 0 failures
But the code is really repetitive and I don’t like that! I think we could improve it with a bit of metaprogramming. This is totally optional and won’t change any functionality, so implement it only if you want to. I know some developers dislike metaprogramming because it can make the code less readable, but, in my opinion, it makes the code cleaner and more compact.
The logic is the same but now, instead of implementing each method manually, we use a hash containing a key (the method name) and a value (the class instance variable name). All we have to do is loop through this hash to define our methods!
# app/presenters/base_presenter.rb
class BasePresenter
CLASS_ATTRIBUTES = {
build_with: :build_attributes,
related_to: :relations,
sort_by: :sort_attributes,
filter_by: :filter_attributes
}
CLASS_ATTRIBUTES.each { |k, v| instance_variable_set("@#{v}", []) }
class << self
attr_accessor *CLASS_ATTRIBUTES.values
CLASS_ATTRIBUTES.each do |k, v|
define_method k do |*args|
instance_variable_set("@#{v}", args.map(&:to_s))
end
end
end
# Hidden Code
# accessors, initialize, ...
end
Thanks to the tests we wrote, we can be sure that refactoring the code didn’t break anything.
rspec spec/presenters/base_presenter_spec.rb
Success (GREEN)
...
Finished in 0.05242 seconds (files took 2.26 seconds to load)
8 examples, 0 failures
BookPresenter
ClassNow we can quickly update the book presenter by using the new methods we just created: related_to
, sort_by
and filter_by
.
# app/presenters/book_presenter.rb
class BookPresenter < BasePresenter
build_with :id, :title, :subtitle, :isbn_10, :isbn_13, :description,
:released_on, :publisher_id, :author_id, :created_at, :updated_at,
:cover
related_to :publisher, :author
sort_by :id, :title, :released_on, :created_at, :updated_at
filter_by :id, :title, :isbn_10, :isbn_13, :released_on, :publisher_id,
:author_id
def cover
@object.cover.url.to_s
end
end
It’s now time to push our changes to GitHub. The flow is the same as in the previous chapter. First, however, let’s run our test suite to ensure that everything is still working.
rspec
Success (GREEN)
...
Finished in 1.34 seconds (files took 2.2 seconds to load)
50 examples, 0 failures
Great! Here is the list of steps to push the code.
Check the changes.
git status
On branch master
Changes not staged for commit:
(use "git add <file>..." to update what will be committed)
(use "git checkout -- <file>..." to discard changes in working directory)
modified: Gemfile
modified: Gemfile.lock
modified: app/controllers/application_controller.rb
modified: app/controllers/books_controller.rb
modified: app/presenters/base_presenter.rb
modified: app/presenters/book_presenter.rb
modified: spec/presenters/base_presenter_spec.rb
modified: spec/requests/books_spec.rb
Untracked files:
(use "git add <file>..." to include in what will be committed)
app/errors/
app/query_builders/
spec/query_builders/
no changes added to commit (use "git add" and/or "git commit -a")
Stage them.
git add .
Commit the changes.
git commit -m "Add Paginator"
Push to GitHub.
git push origin master
In this chapter, we have implemented our first query builder, the Paginator
. Throughout its implementation we also created useful mechanisms to make our life a little bit simpler.
We have also updated the BasePresenter
class to be ready for all the future query and representation builders.