Martian Chronicles
Evil Martians’ team blog
Back-end

Carve your controllers like Papa Carlo

We, Rails developers, tend to keep our controllers “skinny” (and models “fat”—oh, wait, it’s not true anymore; now we have “fat” services 😉).

We add different layers of abstractions: interactors, policies, query objects, form objects, you name it.

And we still have to write something like this when dealing with query params-based filters:

class EventsController < ApplicationController
  def index
    events = Event.all
                  .page(params[:page] || 1)
                  .order(sort_params)

    if params[:type_filter].in?(%w[published draft])
      events = events.where(
        type: params[:type_filter]
      )
    end

    events = events.future if params[:time_filter] == "future"
    # NOTE: `searched` is a scope or class method defined on the Event model
    events = events.searched(params[:q]) if params[:q].present?

    render json: events
  end

  def sort_params
    sort_by =
      if params[:sort_by].in?(%w[id name started_at])
        params[:sort_by]
      else
        :started_at
      end
    sort_order = params[:sort].in?(%w[asc desc]) ? params[:sort] : :desc
    { sort_by => sort_order }
  end
end

Despite having a non-“skinny” controller, we have the code which is hard to read, test and maintain.

I want to show how we can carve this controller (just like Papa Carlo carved Buratino“Russian Pinocchio”—from a log) using a new gem—Rubanok (which means “hand plane” in Russian).

Rubanok is a general-purpose tool for data transformation driven by Hash-based params.

Ok, that sounds weird 😕

Let just look at our example above: we take our data (Active Record relation, Event.all) and transform it according to the user’s input (params object).

What if we could extract this transformation somewhere out of the controller?

You may ask: “What’s the point of this abstraction”?

There are several reasons:

  • Make our code more readable (less logic branching)
  • Make our code easier to test (and make tests faster)
  • Make our code reusable (e.g., sorting and pagination logic is likely used in other controllers, too).

Let me first show you how the above controller looks when we add Rubanok:

class EventsController < ApplicationController
  def index
    events = planish Event.all
    render json: events
  end
end

That’s it. It couldn’t be slimmer (ok, we can make render json: planish(Event.all)).

What’s hidden under the planish method?

It’s a Rails-specific method (btw, Rubanok itself is Rails-free) that utilizes convention over configuration principle and could be unfolded into the following:

def index
  events = EventsPlane.call(Event.all, params.to_unsafe_h)
  render json: events
end

And the EventsPlane class is where all the magic transformation happens:

class EventsPlane < Rubanok::Plane
  TYPES = %w[draft published].freeze
  SORT_FIELDS = %w[id name started_at].freeze
  SORT_ORDERS = %w[asc desc].freeze

  map :page, activate_always: true do |page: 1|
    raw.page(page)
  end

  map :type_filter do |type_filter:|
    next raw.none unless TYPES.include?(type_filter)

    raw.where(type: type_filter)
  end

  match :time_filter do
    having "future" do
      raw.future
    end

    default { |_time_filter| raw.none }
  end

  map :sort_by, :sort do |sort_by: "started_at", sort: "desc"|
    next raw unless SORT_FIELDS.include?(sort_by) &&
                    SORT_ORDERS.include?(sort)

    raw.order(sort_by => sort)
  end

  map :q do |q:|
    raw.searched(q)
  end
end

The plane class describes how to transform data (accessible via raw method) according to the passed params:

  • Use map to extract key(-s) and apply a transformation if the corresponding values are not empty (i.e., empty strings are ignored); and you can rely on Ruby keyword arguments defaults here–cool, right?
  • Use match take values into account as well when choosing a transformer.

Now we can write tests for our plane in isolation:

describe EventsPlane do
  let(:input) { Event.all }
  # add default transformations
  let(:output) { input.page(1).order(started_at: :desc) }
  let(:params) { {} }

  # we match the resulting SQL query and do not make real queries
  # at all–our tests are fast!
  subject { described_class.call(input, params).to_sql }

  specify "q=?" do
    params[:q] = "wood"

    expect(subject).to eq(output.searched("wood").to_sql)
  end

  specify "type_filter=<valid>" do
    params[:type_filter] = "draft"

    expect(subject).to eq(output.where(type: "draft").to_sql)
  end

  specify "type_filter=<invalid>" do
    params[:type_filter] = "unpublished"

    expect(subject).to eq(output.none.to_sql)
  end

  # ...
end

In your controller/request test all you need is to check that a specific plane has been used:

describe EventsController do
  subject { get :index }

  specify do
    expect { subject }.to have_planished(Event.all)
      .with(EventsPlane)
  end
end

So, Rubanok is good for carving controllers, but we said that it’s general-purpose—let’s prove it with GraphQL example!

module GraphAPI
  module Types
    class Query < GraphQL::Schema::Object
      field :profiles, Types::Profile.connection_type, null: false do
        argument :city, Int, required: false
        argument :home, Int, required: false
        argument :tags, [ID], required: false
        argument :q, String, required: false
      end

      def profiles(**params)
        ProfilesPlane.call(Profile.all, params)
      end
    end
  end
end

It looks like we’ve just invented skinny types 🙂

Check out Rubanok repo for more information and feel free to propose your ideas!

P.S. There is an older gem filterer which implements a similar idea (though in PORO way), but focuses on ActiveRecord and lacks testing support.

P.P.S. Wondering what other abstractions we use to organize code in large applications? Check out my other posts, such as “Crafting user notifications in Rails with Active Delivery” or “Clowne: Clone Ruby models with a smile”, and projects, such as Action Policy and Anyway Config.

Humans! We come in peace and bring cookies. We also care about your privacy: if you want to know more or withdraw your consent, please see the Privacy Policy.