In this chapter, we are going to build the remaining query builders. However, in order to fully use them, we will also have to create the presenter classes for the Author
and Publisher
models.
Sorting is pretty simple in itself. We could just use the order
method that comes with Rails, give it the column to use for sorting and in which direction (ascending or descending), put that in the books controller and we would be done.
But is it going to be enough?
Let’s try that first. Add a new test in the test file for the books controller that will test the sorting.
# spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
describe 'GET /api/books' do
before { books }
context 'default behavior' # Hidden Code
describe 'field picking' # Hidden Code
describe 'pagination' # Hidden Code
describe 'sorting' do
it 'sorts the books by "id desc"' do
get('/api/books?sort=id&dir=desc')
expect(json_body['data'].first['id']).to eq agile_web_dev.id
expect(json_body['data'].last['id']).to eq ruby_microscope.id
end
end # describe 'sorting' end
end
end
Run the request tests for books.
rspec spec/requests/books_spec.rb
And see the test we added fail.
Failure (RED)
...
Finished in 1.06 seconds (files took 1.96 seconds to load)
14 examples, 1 failure
To make it pass, we can use the technique we talked about earlier and simply use this line:
books = Book.order("#{params[:sort] || 'id'} #{params[:dir] || 'desc'}")
Plug it into the index
action of the books
controller.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
books = Book.order("#{params[:sort] || 'id'} #{params[:dir] || 'desc'}")
books = paginate(books).map do |book|
FieldPicker.new(BookPresenter.new(book, params)).pick
end
render json: { data: books }.to_json
end
end
If we run the sorting test now, it’s working properly.
rspec spec/requests/books_spec.rb
Success (GREEN)
...
Finished in 0.91801 seconds (files took 1.95 seconds to load)
14 examples, 0 failures
But that’s only considering a smart client that will only send a valid column name and sorting direction. What would happen if the client tried to sort with fid
(fake id) instead of id
?
Let’s add a test to check that. In this test, our expectation is that the server should return 400 Bad Request
because the client didn’t build the request correctly.
Note that we also added a new context for the sorting test we wrote earlier.
# spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
describe 'GET /api/books' do
context 'default behavior' # Hidden COde
describe 'field picking' # Hidden Code
describe 'pagination' # Hidden Code
describe 'sorting' do
context 'with valid column name "id"' do
it 'sorts the books by "id desc"' do
get('/api/books?sort=id&dir=desc')
expect(json_body['data'].first['id']).to eq agile_web_dev.id
expect(json_body['data'].last['id']).to eq ruby_microscope.id
end
end
context 'with invalid column name "fid"' do
it 'gets "400 Bad Request" back' do
get '/api/books?sort=fid&dir=asc'
expect(response.status).to eq 400
end
end
end # describe 'sorting' end
end
end
Let’s try to run the tests.
rspec spec/requests/books_spec.rb
It fails, of course, but it’s worse than we thought. There is an exception being raised because our code tried to run the order query all the way to the SQL database. Not good.
Failure (RED)
...
1) Books GET /api/books sorting with invalid column name "fid" gets
"400 Bad Request"
Failure/Error: get '/api/books?col=fid&dir=asc'
ActiveRecord::StatementInvalid:
SQLite3::SQLException: no such column: fid: SELECT "books".* FROM "books"
ORDER BY fid asc LIMIT ? OFFSET ?
...
Finished in 1.13 seconds (files took 2.55 seconds to load)
15 examples, 1 failure
To avoid that, we need to ensure that the column and the direction sent from the client are valid. We cannot put that in the books
controller, it’s not its job after all. Instead, we should create a class to handle sorting.
In the same way we created the Paginator
, we’re now going to implement a new query builder: the Sorter
!
We need two new files - one to hold the Sorter
class and one for its tests. Create them manually or use the command below.
touch app/query_builders/sorter.rb spec/query_builders/sorter_spec.rb
Create the Sorter
class to avoid having our tests failing because the class doesn’t exist.
# app/query_builders/sorter.rb
class Sorter
end
Next, it’s time to write some tests. We are going to create 3 different tests in 2 different contexts.
And here is the complete sorter_spec
file with the tests implemented.
# spec/query_builders/sorter_spec.rb
require 'rails_helper'
RSpec.describe Sorter 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) { HashWithIndifferentAccess.new({ sort: 'id', dir: 'desc' }) }
let(:sorter) { Sorter.new(scope, params) }
let(:sorted) { sorter.sort }
before do
allow(BookPresenter).to(
receive(:sort_attributes).and_return(['id', 'title'])
)
books
end
describe '#sort' do
context 'without any parameters' do
let(:params) { {} }
it 'returns the scope unchanged' do
expect(sorted).to eq scope
end
end
context 'with valid parameters' do
it 'sorts the collection by "id desc"' do
expect(sorted.first.id).to eq agile_web_dev.id
expect(sorted.last.id).to eq ruby_microscope.id
end
it 'sorts the collection by "title asc"' do
expect(sorted.first).to eq agile_web_dev
expect(sorted.last).to eq ruby_microscope
end
end
end
end
Let’s see how our tests are performing.
rspec spec/query_builders/sorter_spec.rb
Failure (RED)
...
Finished in 0.30607 seconds (files took 2.44 seconds to load)
3 examples, 3 failures
...
To fix them, we need to add some code in the Sorter
class. Nothing too complicated, the logic is pretty close to the paginator.
# app/query_builders/sorter.rb
class Sorter
def initialize(scope, params)
@scope = scope
@column = params[:sort]
@direction = params[:dir]
end
def sort
return @scope unless @column && @direction
@scope.order("#{@column} #{@direction}")
end
end
With that, all our tests should pass.
rspec spec/query_builders/sorter_spec.rb
Success (GREEN)
...
Sorter
#sort
without any parameters
returns the scope unchanged
with valid parameters
sorts the collection by "id desc"
sorts the collection by "title asc"
Finished in 0.37913 seconds (files took 1.83 seconds to load)
3 examples, 0 failures
Once again, we forgot to handle what happens with invalid sorting parameters like fid
. We can add a test to the sorter_spec
to ensure that we never forget again. To write this test, we use raise_error(QueryBuilderError)
which, unlike our other tests, requires a block to be passed to expect
.
# spec/query_builders/sorter_spec.rb
require 'rails_helper'
RSpec.describe Sorter do
# Hidden Code: let definitions
describe '#sort' do
context 'without any parameters' # Hidden Code
context 'with valid parameters' # Hidden Code
context 'with invalid parameters' do
let(:params) { HashWithIndifferentAccess.new({ sort: 'fid', dir: 'desc' }) }
it 'raises a QueryBuilderError exception' do
expect { sorted }.to raise_error(QueryBuilderError)
end
end
end
end
Run the Sorter
tests.
rspec spec/query_builders/sorter_spec.rb
Failure (RED)
...
Failures:
1) Sorter#sort with invalid parameters raises a QueryBuilderError exception
Failure/Error: expect { sorted }.to raise_error(QueryBuilderError)
expected QueryBuilderError but nothing was raised
Finished in 0.6664 seconds (files took 2.4 seconds to load)
4 examples, 1 failure
To raise a QueryBuilderError
when wrong parameters are given, we need to have a place to check the allowed values. That’s what we did in the book presenter, remember?
# app/presenters/book_presenter.rb
class BookPresenter < BasePresenter
# Hidden Code
sort_by :id, :title, :released_on, :created_at, :updated_at
# Hidden Code
end
We can use this list of columns to check that the passed value for sort
is valid. To do this, we need to have the book presenter class, which we can easily get with the following line of code:
@presenter = "#{@scope.model}Presenter".constantize
@scope.model
will return Book
in this case and we will have the BookPresenter
class ready to give us the sorting attributes by calling @presenter.sort_attributes
. This will also work with any other presenter in the future!
We also need a list of the valid directions; a constant should be enough for this, as you can see in the code below with the DIRECTIONS
constant. Finally, we need a method to actually raise the exception, and that will be the error!
method duty.
# app/query_builders/sorter.rb
class Sorter
DIRECTIONS = %w(asc desc)
def initialize(scope, params)
@scope = scope
@presenter = "#{@scope.model}Presenter".constantize
@column = params[:sort]
@direction = params[:dir]
end
def sort
return @scope unless @column && @direction
# Valid column?
error!('sort', @column) unless @presenter.sort_attributes.include?(@column)
# Valid direction?
error!('dir', @direction) unless DIRECTIONS.include?(@direction)
@scope.order("#{@column} #{@direction}")
end
private
def error!(name, value)
columns = @presenter.sort_attributes.join(',')
raise QueryBuilderError.new("#{name}=#{value}"),
"Invalid sorting params. sort: (#{columns}), 'dir': asc,desc"
end
end
Our test is now working!
rspec spec/query_builders/sorter_spec.rb
Success (GREEN)
...
Finished in 0.39346 seconds (files took 1.91 seconds to load)
4 examples, 0 failures
Let’s go back to the request specs for the books
controller now. Before we try to run the previously failing test, we are going to add two more tests: one to ensure that we are getting the error JSON
document back and one that checks that the value in the invalid_params
is correct.
# spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
describe 'GET /api/books' do
context 'default behavior' # Hidden Code
describe 'field picking' # Hidden Code
describe 'pagination' # Hidden Code
describe 'sorting' do
context 'with valid column name "id"' do
it 'sorts the books by "id desc"' do
get('/api/books?sort=id&dir=desc')
expect(json_body['data'].first['id']).to eq agile_web_dev.id
expect(json_body['data'].last['id']).to eq ruby_microscope.id
end
end
context 'with invalid column name "fid"' do
before { get '/api/books?sort=fid&dir=asc' }
it 'gets "400 Bad Request" back' do
expect(response.status).to eq 400
end
it 'receives an error' do
expect(json_body['error']).to_not be nil
end
it 'receives "sort=fid" as an invalid param' do
expect(json_body['error']['invalid_params']).to eq 'sort=fid'
end
end
end # describe 'sorting' end
end
end
Run them.
rspec spec/requests/books_spec.rb
Failure (RED)
...
Finished in 1.52 seconds (files took 1.95 seconds to load)
17 examples, 3 failures
...
Oops. We forgot to actually use the sorter in the books
controller. Duh! Just as we did for the Paginator
class, let’s add a method to make it easier to sort our resources in the ApplicationController
class.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from QueryBuilderError, with: :query_builder_error
protected
def query_builder_error # Hidden Code
def sort(scope)
Sorter.new(scope, params).sort
end
def paginate # Hidden Code
def current_url # Hidden Code
end
Use that method in the books controller to have the books
scope go through the Sorter
builder.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
books = sort(paginate(Book.all)).map do |book|
FieldPicker.new(BookPresenter.new(book, params)).pick
end
render json: { data: books }.to_json
end
end
Try running the tests again.
rspec spec/requests/books_spec.rb
Success (GREEN)
...
Finished in 1.82 seconds (files took 2.76 seconds to load)
17 examples, 0 failures
Great, it’s working. The Sorter
is now ready.
We currently have two query builders: the Paginator
and the Sorter
. We still need to create the Filter
plus a smaller, optional one, the EagerLoader
.
Let’s start with the Filter
query builder. This is the most complex query builder, so we’re going to take it slower than usual.
First, how can the client communicate the filtering options? To avoid conflict with other parameters (for sorting, pagination, etc.), all the filtering parameters should be in the q
parameter. That means if I want to get all the books with “Ruby” as title, I could use ?q[title]=Ruby
in the URI. But this query is quite limited since the client can only filter by exact match.
To create a more powerful filtering system, we are going to include predicates in the parameters. For example, if I want to get all the books with a title starting with “Ruby”, I could do ?q[title_start]=Ruby
. All the books with the title
containing “Ruby”? ?q[title_cont]=Ruby
.
For this implementation, we are going to define 7 predicates. With a value ‘X’, here is how they will work:
eq
: field value equals X (exact match).
cont
: field value contains X.
notcont
: field value does not contain X.
start
: field value starts with X.
end
: field value ends with X.
gt
: field value is greater than X. (gt
= greater-than sign ‘>’)
lt
: field value is less than X. (lt
= less-than sign ‘<’)
Let’s get started implementing the Filter
builder.
Filter
ClassCreate a file to hold the new class and another one for its tests.
touch app/query_builders/filter.rb spec/query_builders/filter_spec.rb
Put the minimum amount of code to have a Filter
class.
# app/query_builders/filter.rb
class Filter
end
Now, let’s write our first test. For now, all we want to check is that the Filter
class returns the scope unchanged if there were no passed parameters.
# spec/query_builders/filter_spec.rb
require 'rails_helper'
RSpec.describe Filter 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) { {} }
let(:filter) { Filter.new(scope, params) }
let(:filtered) { filter.filter }
before do
allow(BookPresenter).to(
receive(:filter_attributes).and_return(['id', 'title', 'released_on'])
)
books
end
describe '#filter' do
context 'without any parameters' do
it 'returns the scope unchanged' do
expect(filtered).to eq scope
end
end
end
end
Run the tests.
rspec spec/query_builders/filter_spec.rb
And watch them fail.
Failure (RED)
...
Finished in 0.24567 seconds (files took 1.97 seconds to load)
1 example, 1 failure
...
We can fix the Filter
class by simply returning the @scope
variable when no filtering parameters were given.
# app/query_builders/filter.rb
class Filter
def initialize(scope, params)
@scope = scope
@presenter = "#{@scope.model}Presenter".constantize
@filters = params['q'] || {}
end
def filter
return @scope unless @filters.any?
@scope
end
end
This should be enough to make our test pass.
rspec spec/query_builders/filter_spec.rb
Success (GREEN)
...
Filter
#filter
without any parameters
returns the scope unchanged
Finished in 0.18667 seconds (files took 2.36 seconds to load)
1 example, 0 failures
Now we are getting to the fun part! We are going to write tests for the predicates logic - just to be safe, let’s write one test for each predicate.
# spec/query_builders/filter_spec.rb
require 'rails_helper'
RSpec.describe Filter do
# Hidden Code: let definitions and before block
describe '#filter' do
context 'without any parameters' # Hidden Code
context 'with valid parameters' do
context 'with "title_eq=Ruby Under a Microscope"' do
let(:params) { { 'q' => { 'title_eq' => 'Ruby Under a Microscope' } } }
it 'gets only "Ruby Under a Microscope" back' do
expect(filtered.first.id).to eq ruby_microscope.id
expect(filtered.size).to eq 1
end
end
context 'with "title_cont=Under"' do
let(:params) { { 'q' => { 'title_cont' => 'Under' } } }
it 'gets only "Ruby Under a Microscope" back' do
expect(filtered.first.id).to eq ruby_microscope.id
expect(filtered.size).to eq 1
end
end
context 'with "title_notcont=Ruby"' do
let(:params) { { 'q' => { 'title_notcont' => 'Ruby' } } }
it 'gets only "Agile Web Development with Rails 4" back' do
expect(filtered.first.id).to eq agile_web_dev.id
expect(filtered.size).to eq 1
end
end
context 'with "title_start=Ruby"' do
let(:params) { { 'q' => { 'title_start' => 'Ruby' } } }
it 'gets only "Ruby Microscope and Ruby on Rails Tutorial" back' do
expect(filtered.size).to eq 2
end
end
context 'with "title_end=Tutorial"' do
let(:params) { { 'q' => { 'title_end' => 'Tutorial' } } }
it 'gets only "Ruby Microscope and Ruby on Rails Tutorial" back' do
expect(filtered.first).to eq rails_tutorial
end
end
context 'with "released_on_lt=2013-05-10"' do
let(:params) { { 'q' => { 'released_on_lt' => '2013-05-10' } } }
it 'gets only the book with released_on before 2013-05-10' do
expect(filtered.first.title).to eq rails_tutorial.title
expect(filtered.size).to eq 1
end
end
context 'with "released_on_gt=2013-01-01"' do
let(:params) { { 'q' => { 'released_on_gt' => '2014-01-01' } } }
it 'gets only the book with id 1' do
expect(filtered.first.title).to eq agile_web_dev.title
expect(filtered.size).to eq 1
end
end
end # context 'with valid parameters' end
end
end
I know that the file is big, but we need those tests. Let’s try to run them.
rspec spec/query_builders/filter_spec.rb
Failure (RED)
...
Finished in 0.64238 seconds (files took 1.85 seconds to load)
8 examples, 7 failures
...
We now need to implement the predicates logic in the Filter
class. In the code below, we added two major steps. The first one is the call to the format_filters
method and the second one the call to build_filter_scope
.
format_filters
is responsible for extracting the needed information from the parameters.
def format_filters
@filters.each_with_object({}) do |(key, value), hash|
hash[key] = {
value: value,
column: key.split('_')[0...-1].join('_'),
predicate: key.split('_').last
}
end
end
When this method gets…
{ 'title_cont' => 'Ruby' }
it will return a hash containing all the information we need:
`{ value: 'Ruby', column: 'title', predicate: 'cont' }`
After that, the build_filter_scope
method loops through the filters and calls the associated predicate method with the column name and the filtering value.
def build_filter_scope
@filters.each do |key, data|
@scope = send(data[:predicate], data[:column], data[:value])
end
end
The entire class looks like this. Notice the methods created for each predicate are just going to run a where
query.
# app/query_builders/filter.rb
class Filter
PREDICATES = %w(eq cont notcont start end gt lt)
def initialize(scope, params)
@scope = scope
@presenter = "#{@scope.model}Presenter".constantize
@filters = params['q'] || {}
end
def filter
return @scope unless @filters.any?
@filters = format_filters
build_filter_scope
@scope
end
private
def format_filters
@filters.each_with_object({}) do |(key, value), hash|
hash[key] = {
value: value,
column: key.split('_')[0...-1].join('_'),
predicate: key.split('_').last
}
end
end
def build_filter_scope
@filters.each do |key, data|
@scope = send(data[:predicate], data[:column], data[:value])
end
end
def eq(column, value)
@scope.where(column => value)
end
def cont(column, value)
@scope.where("#{column} LIKE ?", "%#{value}%")
end
def notcont(column, value)
@scope.where("#{column} NOT LIKE ?", "%#{value}%")
end
def start(column, value)
@scope.where("#{column} LIKE ?", "#{value}%")
end
def end(column, value)
@scope.where("#{column} LIKE ?", "%#{value}")
end
def gt(column, value)
@scope.where("#{column} > ?", value)
end
def lt(column, value)
@scope.where("#{column} < ?", value)
end
end
Let’s see how our tests are doing now.
rspec spec/query_builders/filter_spec.rb
Success (GREEN)
...
Filter
#filter
without any parameters
returns the scope unchanged
with valid parameters
with "title_eq=Ruby Under a Microscope"
gets only "Ruby Under a Microscope" back
with "title_cont=Under"
gets only "Ruby Under a Microscope" back
with "title_notcont=Ruby"
gets only "Agile Web Development with Rails 4" back
with "title_start=Ruby"
gets only "Ruby Microscope and Ruby on Rails Tutorial" back
with "title_end=Tutorial"
gets only "Ruby Microscope and Ruby on Rails Tutorial" back
with "released_on_lt=2013-05-10"
gets only the book with released_on before 2013-05-10
with "released_on_gt=2013-01-01"
gets only the book with id 1
Finished in 0.21978 seconds (files took 0.82017 seconds to load)
8 examples, 0 failures
Awesome - now they’re running properly.
We just have one last thing to add to the Filter
class, and that’s raising a QueryBuilderError
if we receive some filtering parameters that are invalid. Let’s add two tests - one when the column name is invalid, and one when the predicate is not supported.
# spec/query_builders/filter_spec.rb
require 'rails_helper'
RSpec.describe Filter do
# Hidden Code: let definitions and before
describe '#filter' do
context 'without any parameters' # Hidden Code
context 'with valid parameters' # Hidden Code
context 'with invalid parameters' do
context 'with invalid column name "fid"' do
let(:params) { { 'q' => { 'fid_gt' => '2' } } }
it 'raises a "QueryBuilderError" exception' do
expect { filtered }.to raise_error(QueryBuilderError)
end
end
context 'with invalid predicate "gtz"' do
let(:params) { { 'q' => { 'id_gtz' => '2' } } }
it 'raises a "QueryBuilderError" exception' do
expect { filtered }.to raise_error(QueryBuilderError)
end
end
end # context 'with invalid parameters' end
end
end
rspec spec/query_builders/filter_spec.rb
Failure (RED)
...
Finished in 0.77711 seconds (files took 2.32 seconds to load)
10 examples, 2 failures
...
In order to raise an error, we are going to loop through the formatted filters and check if the predicate is supported and if the presenter allows this column for filtering.
# app/query_builders/filter.rb
class Filter
PREDICATES = %w(eq cont notcont start end gt lt)
def initialize # Hidden Code
def filter
return @scope unless @filters.any?
@filters = format_filters
# Add the validation here
validate_filters
build_filter_scope
@scope
end
private
def format_filters # Hidden Code
def validate_filters
attributes = @presenter.filter_attributes
@filters.each do |key, data|
error!(key, data) unless attributes.include?(data[:column])
error!(key, data) unless PREDICATES.include?(data[:predicate])
end
end
def error!(key, data)
columns = @presenter.filter_attributes.join(',')
pred = PREDICATES.join(',')
raise QueryBuilderError.new("q[#{key}]=#{data[:value]}"),
"Invalid Filter params. Allowed columns: (#{columns}), 'predicates': #{pred}"
end
def build_filter_scope # Hidden Code
def eq # Hidden Code
def cont # Hidden Code
def notcont # Hidden Code
def start # Hidden Code
def end # Hidden Code
def gt # Hidden Code
def lt # Hidden Code
end
The tests are now running correctly and the Filter
class is ready to be used.
rspec spec/query_builders/filter_spec.rb
Success (GREEN)
...
Finished in 0.99595 seconds (files took 2.19 seconds to load)
10 examples, 0 failures
As usual, let’s add some tests to the books
spec to test the filtering feature.
# spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
# Hidden Code: let definitions and before
describe 'GET /api/books' do
context 'default behavior' # Hidden Code
describe 'field picking' # Hidden Code
describe 'pagination' # Hidden Code
describe 'sorting' # Hidden Code
describe 'filtering' do
context 'with valid filtering param "q[title_cont]=Microscope"' do
it 'receives "Ruby under a microscope" back' do
get('/api/books?q[title_cont]=Microscope')
expect(json_body['data'].first['id']).to eq ruby_microscope.id
expect(json_body['data'].size).to eq 1
end
end
context 'with invalid filtering param "q[ftitle_cont]=Microscope"' do
before { get('/api/books?q[ftitle_cont]=Ruby') }
it 'gets "400 Bad Request" back' do
expect(response.status).to eq 400
end
it 'receives an error' do
expect(json_body['error']).to_not be nil
end
it 'receives "q[ftitle_cont]=Ruby" as an invalid param' do
expect(json_body['error']['invalid_params']).to eq 'q[ftitle_cont]=Ruby'
end
end
end # describe 'filtering' end
end
end
Give it a try.
rspec spec/requests/books_spec.rb
Failure (RED)
...
Finished in 1.55 seconds (files took 2.54 seconds to load)
21 examples, 4 failures
...
Like for the Paginator
and the Sorter
, a little method in the ApplicationController
would make our life a tiny bit easier.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
rescue_from QueryBuilderError, with: :query_builder_error
protected
def query_builder_error # Hidden Code
def filter(scope)
Filter.new(scope, params.to_unsafe_hash).filter
end
def sort # Hidden Code
def paginate # Hidden Code
def current_url # Hidden Code
end
And let’s use this method in the books
controller.
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def index
books = filter(sort(paginate(Book.all))).map do |book|
FieldPicker.new(BookPresenter.new(book, params)).pick
end
render json: { data: books }.to_json
end
end
It’s getting crowded in there, so we will need to do a bit of refactoring later on. For now, let’s see if everything is working as expected.
rspec spec/requests/books_spec.rb
Success (GREEN)
...
Finished in 1.49 seconds (files took 2.73 seconds to load)
21 examples, 0 failures
If you’d like, go and play with the filtering we just implemented. First, start the server.
rails s
Then try to access the URLs below.
Feel free to try different filters or combine multiple filters together.
We’ve already built the main query builders. However, there is another small one that we need to add to make our future representation builders more efficient.
In the next chapter, we will allow clients to ask for the embedding of related entities inside the same JSON
document. For example, a client could request all the authors and ask for the books to be embedded:
[
"author1": {
"id": 1,
"books": [{
"id": 1,
"title": "Sample"
}, { ... }]
}
]
We could also allow clients to request the identifiers of all related entities. We would end up with something like this:
[
"author1": {
"id": 1,
"books": [1, 2, 3, 6]
}
]
We won’t be implementing this, but it shouldn’t be too hard to create a new builder for that purpose.
To avoid having our representation builders making multiple SQL queries, we need a way to specify relations that should be “eager loaded”. That’s exactly what the EagerLoader
builder will do.
To implement it, we need the Author
and Publisher
presenters, so let’s create them right now.
touch app/presenters/author_presenter.rb app/presenters/publisher_presenter.rb
Put this in the author presenter.
# app/presenters/author_presenter.rb
class AuthorPresenter < BasePresenter
related_to :books
sort_by :id, :given_name, :family_name, :created_at, :updated_at
filter_by :id, :given_name, :family_name, :created_at, :updated_at
build_with :id, :given_name, :family_name, :created_at, :updated_at
end
And that in the publisher presenter.
# app/presenters/publisher_presenter.rb
class PublisherPresenter < BasePresenter
related_to :books
sort_by :id, :name, :created_at, :updated_at
filter_by :id, :name, :created_at, :updated_at
build_with :id, :name, :created_at, :updated_at
end
EagerLoader
ClassJust for a change, we are not going to write tests for the EagerLoader
. Instead, I’d like you to write tests for it. You don’t have to write them first however; as long as you write some tests I’ll be happy.
First, Create the needed files.
touch app/query_builders/eager_loader.rb spec/query_builders/eager_loader_spec.rb
And here is the complete code for the EagerLoader
. All it does is check the params
hash for the embed
or include
properties, check that they contain valid values and call includes
on the scope to eager load the specified relations.
# app/query_builders/eager_loader.rb
class EagerLoader
def initialize(scope, params)
@scope = scope
@presenter = "#{@scope.model}Presenter".constantize
@embed = params[:embed] ? params[:embed].split(',') : []
@include = params[:include] ? params[:include].split(',') : []
end
def load
return @scope unless @embed.any? || @include.any?
validate!('embed', @embed)
validate!('include', @include)
(@embed + @include).each do |relation|
@scope = @scope.includes(relation)
end
@scope
end
private
def validate!(name, params)
params.each do |param|
unless @presenter.relations.include?(param)
raise QueryBuilderError.new("#{name}=#{param}"),
"Invalid #{name}. Allowed relations: #{@presenter.relations.join(',')}"
end
end
end
end
That’s good enough for now. We’ll start using the EagerLoader
later when we can actually see the difference it makes.
Let’s push our changes.
rspec
Success (GREEN)
...
Finished in 3.79 seconds (files took 2.02 seconds to load)
72 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: app/controllers/application_controller.rb
modified: app/controllers/books_controller.rb
modified: spec/requests/books_spec.rb
Untracked files:
(use "git add <file>..." to include in what will be committed)
app/presenters/author_presenter.rb
app/presenters/publisher_presenter.rb
app/query_builders/eager_loader.rb
app/query_builders/filter.rb
app/query_builders/sorter.rb
spec/query_builders/eager_loader_spec.rb
spec/query_builders/filter_spec.rb
spec/query_builders/sorter_spec.rb
no changes added to commit (use "git add" and/or "git commit -a")
Stage them.
git add .
Commit the changes.
git commit -m "Create Sorter, Filter and EagerLoader"
Output
[master 1eb5daa] Create Sorter, Filter and EagerLoader
11 files changed, 397 insertions(+), 10 deletions(-)
create mode 100644 app/presenters/author_presenter.rb
create mode 100644 app/presenters/publisher_presenter.rb
create mode 100644 app/query_builders/eager_loader.rb
create mode 100644 app/query_builders/filter.rb
create mode 100644 app/query_builders/sorter.rb
create mode 100644 spec/query_builders/eager_loader_spec.rb
create mode 100644 spec/query_builders/filter_spec.rb
create mode 100644 spec/query_builders/sorter_spec.rb
Push to GitHub.
git push origin master
This chapter was all about query builders. We have built the Paginator
, the Sorter
and the EagerLoader
, and the good news is that we are done building query builders. However, we still have to build some representation builders before working on the full-text search.