UPDATE Turns out binding_of_caller
is not recommended for production apps
Rails project, RetailersController, products action, circa 2013 AD:
def products
@products = current_retailer.
products.order('created_at desc').
paginate(page: params[:page], per_page: 20)
end
That query is not too bad. I can totally live with it. But I’ve been roaming around this controller for the last few days and every time I am passing by this piece of code it just does not make the journey better. The effect is somewhat enhanced by three other actions that look identical to this one (except they query for other things). So today I finally stopped and decided to sort it out like a pro! Well. That, and I had to to put some tests around those actions and stubbing out a query such as the one above is just so much the opposite of fun… But that would be a boring start for a post.
Despite Rails community actively exploring The Right Way™ to deal with the logic that does not fit into Rails MVC, the most common thing amongst those who just need to Get Shit Done™ is to move the query logic into model. Ok. Second most common. The first one admittedly is to leave it as is and get some shit done instead. However, I do not belong to that category of people, since I am writing this post instead of getting shit done. And neither are you, since you are reading this post instead of getting shit done. So let us disregard those lucky bastards and see what is down that rabbit hole.
Ok, moving the query into the model is not cool. So what is? I don’t know! And when I don’t know, I tend to start with making stuff up. What would an ideal client code look like? It would be nice to have something that is easy to stub out in controller test. Also, it would make it more readable to see where the products are coming from. And certainly none of the pagination/sorting machinery should make it through. Something like this:
def products
@products = PrepareForTableView.relation(current_retailer.products)
end
This way retailer is still aware of its products but on the other hand controller does not need to know how to sort and paginate. Also, it looks like a decorator. Or presenter? Whatever. Design patterns - check.
The slightly annoying problem however is that it is not going to work. At least not without also passing params[:page]
. Ok. How does it look now?
@products = PrepareForTableView.relation(current_retailer.products, page: params[:page])
Acceptable. But not as good as before. What can be done?
Conventions work great for Rails. So let us introduce one. The one that postulates that PrepareForTableView
is only ever going to be used in a controller. So what? Well that means that its methods will always be called from the context of a controller. If only we could get hold of that context. Oh, wait. Isn’t it what ruby Binding
is for?
Quick recap:
Objects of class Binding encapsulate the execution context at some particular place in the code and retain this context for future use. The variables, methods, value of self, and possibly an iterator block that can be accessed in this context are all retained.
Ok, but how is this useful? The profit comes from the fact that ruby can eval
stuff in specified context (binding). Like this:
page = controller_context.eval("params[:page]")
So all that is needed for our case is access to binding of caller. binding_of_caller gem does just that (and does it well):
module PrepareForTableView
module_function
def relation relation
page = binding.of_caller(1).eval("params[:page]")
relation.order('created_at desc').paginate(page: page, per_page: 20)
end
end
Good.
Now let us finally get some shit done and allow retailer to download products in xls. xls version does not require sorting. Also our client is specific about having more than one page worth of data in there. Having controller context at hand gives our module a lot of flexibility so the above can be achieved without even changing client code:
# renamed as it is no longer just about table view
module WithinRequestContext
module_function
def adjust_data relation
request, params = binding.of_caller(1).eval('[request, params]')
if request.format == Mime::HTML
relation.order('created_at desc').paginate(page: params[:page], per_page: 20)
else
relation
end
end
end
The client code remains the same:
def products
@products = WithinRequestContext.adjust_data(current_retailer.products)
end
Last, but not least. Unit testing this module is simple. The test just needs to have params
and request
defined:
describe WithinRequestContext do
let(:request) { stub('request') }
let(:params) { {page: 1} }
let(:relation) { stub('relation') }
before do
# relation follows builder pattern, so should the stub
relation.stub(:paginate).and_return(relation)
relation.stub(:order).and_return(relation)
end
describe "#adjust_data" do
context "Non HTML request" do
before do
request.stub(:format).and_return('some mime type')
end
it "returns unmodified relation" do
relation.should_not_receive(:order)
relation.should_not_receive(:paginate)
WithinRequestContext.adjust_data(relation).should == relation
end
end
context "HTML request" do
before do
request.stub(:format).and_return(Mime::HTML)
end
it "paginates" do
relation.should_receive(:paginate).with(page: 1, per_page: 20)
WithinRequestContext.adjust_data(relation).should == relation
end
it "orders by created_at desc" do
relation.should_receive(:order).with('created_at desc')
WithinRequestContext.adjust_data(relation).should == relation
end
end
end
end
That is all I’ve got on dependency lookup with binding_of_caller - an awesome little gem used by pry-stack_explorer and better_errors.
It is worth mentioning that it is all fun and games until someone gets to maintain it. I’ll wait till then before start recommending the above approach… But you don’t have to!