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
mapto 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
matchtake 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-schemais 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