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)
events = events.where(
type: params[:type_filter]
) if params[:type_filter].in?(%w[published draft])
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 = params[:sort_by].in?(%w[id name started_at]) ?
params[:sort_by] :
:started_at
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.
Read more dev articles on https://evilmartians.com/chronicles!
Top comments (4)
Oh I like it. I'm going to give it a try. Would you put Plane classes in it's own folder under app?
Yeah, I put them under
app/planes
.Thanks for the question. Added this to the Readme
It'd be great to hear why is your solution is better than others, for example: dry-schema
dry-schema
is a totally different toolRubanok doesn't do neither validation nor coercion, it takes input data and pass it through the transformers according to the params