Chapter 17

Query Builders

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.

17.1. Sorting Query Builder: Sorter

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?

17.1.1. Sorting in the Controller

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.

17.1.2. No! Extracting the Logic in a Dedicated Class

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.

  • without any parameters
    • returns the scope unchanged
  • with valid parameters
    • sorts the collection by ‘id desc’
    • sorts the collection by ‘title asc’

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

17.1.3. Raising a QueryBuilderError

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

17.1.4. Fixing the Books Controller

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.

17.2. Filtering Query Builder: Filter

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.

17.2.1. Implementing the Filter Class

Create 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

17.2.2. Handling Filtering

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.

17.2.3. Raising a QueryBuilderError

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

17.2.4. Updating the Books Controller

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

17.2.5. Playing with Filtering

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.

https://s3.amazonaws.com/devblast-mrwa-book/images/figures/17/title_eq
Figure 1

Feel free to try different filters or combine multiple filters together.

17.3. The EagerLoader Builder

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.

17.3.1. Adding the Author and Publisher Presenters

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

17.3.2. Implementing the EagerLoader Class

Just 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.

17.4. Pushing Our Changes

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

17.5. Wrap Up

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.