Integrating Pagy with Hanami

Pagy – “the ultimate pagination gem that outperforms the others in each and every benchmark and comparison” – made all Ruby-related news lately. It is no surprise, after all it promises a lot: being simple, being fast, being ORM-agnostic and working with every Rack-based framework.

All of these are really good features. Afer all, it’s true what the gem’s website says: pagination is not a rocket science and it should be kept simple, stupid. Current solutions, namely will_paginate and kaminary, grew very complex and tightly coupled to Ruby on Rails. We really needed (and deserved) something better. And I’m glad it’s here.

However, I’m going to check its other promises: working with every ORM and every framework. To do so, I present you a tutorial about integrating Pagy with Hanami.

Preparations

Let’s start by taking a demo Hanami app - “Bookshelf” from here. I clone it and then make one little adjustment in apps/web/templates/books/index.html.erb, changing from ugly div-based displaying of books into unordered list:

<ul>
  <% books.each do |book| %>
    <li class="book">
      <p><%= book.author %>: <em><%= book.title %></em></p>
    </li>
  <% end %>
</ul>

This is an extra step, of course you don’t need to do this. Another optional thing is to create a Rake task to fill our database with sample data. Here’s my code, using Faker gem (in Rakefile):

task :seed do
  require 'faker'
  require_relative 'config/environment'
  Hanami.boot

  number = ENV['NUMBER'] || 40
  number.times do
    author = Faker::FunnyName.name
    title = Faker::Fallout.quote
    BookRepository.new.create(author: author, title: title)
  end
end

Controller

Now let’s turn to real task. I added gem 'pagy' to my Gemfile and then followed the tutorial.

In Web::Controllers::Books I added required inclusion line: include Pagy::Backend and then edided its call method to include using Pagy:

expose :books, :pagy_data

def call(params)
  @pagy_data, @books = pagy(BookRepository.new)
end

There are couple of things to note here:

  • First, we need to add pagy_data to our exposures, since, unlike other frameworks, Hanami does not let you use all the instance variables from controller in views and templates.
  • Second, we cannot use name pagy for the instance variable (like the tutorial suggests), because it would introduce a naming conflict between exposed method and internal pagy mathod. The result is a nasty error about wrong number of arguments, which was quite hard to debug.
  • Instead of a model, we pass a repository to pagy method. Hanami does not use models (unlike ActiveRecord) at all. We have entities and repositories here and repository is a place to put the logic connected to pagination (setting offsets and limits).

Repository

Since we are passing a repository, it’s easy to guess we need to add some code there. Two methods, to be precise. One is count - to calculate number of all records in the database. I don’t quite understand why the method is not htere by default, after all it’s equally useful/useless as all method, but it’s not.

Second thing is actual method for fetchng data from the database. I called it page, which is probably not the best name, but it works in this simple example. Here is the code for those two:

class BookRepository < Hanami::Repository
  def page(offset, size)
    books.offset(offset).limit(size).to_a
  end

  def count
    books.count
  end
end

Back to the controller

This step is the first one where we have to dive a bit into Pagy’s internals. Fortunately, this is described in the docs:

pagy_get_items(collection, pagy)

Sub-method called only by the pagy method, it returns the page items (i.e. the records belonging to the current page).

The original implementation works for ActiveRecord (and other ORMs formed after it, so using active record pattern, such as Mongoid), but does not work for data mapper pattern (like Hanami::Model or ROM). This is how it looks:

def pagy_get_items(collection, pagy)
  collection.offset(pagy.offset).limit(pagy.items)
end

We need to overwrite it to use our repository (passed as first argument). I did it in the controller, but of course, if you are going to have more paginations on more models, it would be a good idea to put it in some separate module and include everywhere:

def pagy_get_items(repo, pagy)
  repo.page(pagy.offset, pagy.items)
end

With that, the so-called backend part is done. Now let’s go to views and templates.

Template

We are going to the template first. The docs tell us to put pagy view methods in it and call it a day. All we need to do here is to replace instance variables @pagy with our pagy_data:

<%= pagy_nav(pagy_data) %>
<%= pagy_info(pagy_data) %>

With little surprise, it does not work. But the rest of the job is to be done in the view.

View

First thing to do is to include Pagy::Frontend. Then we need some custom hacking too. First thing is pagy_url_for. Now, this is more or less where I stopped liking Pagy a bit. Let’s take a look at the original implementation:

def pagy_url_for(page, pagy)
  params = request.GET.merge(pagy.vars[:page_param] => page).merge!(pagy.vars[:params])
  "#{request.path}?#{Rack::Utils.build_nested_query(pagy_get_params(params))}#{pagy.vars[:anchor]}"
end

What it does is assuming there is a request method/object available here in the template. This is a huge violation of MVC, because why on Earth would a template or view need to know about a low-level object from Rack? It shouldn’t. Hanami goes very far in enforcing it and simply disallows you to expose it from the controller.

There is probably a more universal solution out there, but I decided to keep it simple. Here’s how my method looks like:

def pagy_url_for(page, pagy)
  options = { pagy_data.vars[:page_param] => page }.merge(pagy_data.vars[:params])
  routes.path(:books, options)
end

It mixes a little bit of Pagy’s magic (uses its vars object with all the configuration for pagination) with simple Hanami routes. I real-life usage you might want to add a check if page number is one and if it is - not add a page to params.

One last thing to take care of is HTML escaping. Pagy::Frontend generates strings and you can’t just throw them in the template, but first “unescape” (i.e. mark as safe) in the view:

def pagy_nav(*args)
  _raw super
end

def pagy_info(*args)
  _raw super
end

And that’s it! Now you can enjoy working pagination in your Bookshelf application in Hanami.

The complete code can be found in my fork on separate branch.

Verdict

I really like what Pagy attempts to do: bring back Ruby into Rails-world.

However, I had to write quite a lot of custom code to make it work with Hanami. The backend part is actually close to writing this all by hand (as I said, it’s no rocket science). A bit more work is done by Pagy’s frontend part. In the end, I think it should be advertised as ORM-agnostic (if your ORM has API like ActiveRecord) and working on every Rack-based framework (if it’s similar to Rails).

Should you use Pagy or write your own pagination? It’s rather up to you. Now you see how many customizations are required to work with simplest setup. I did not play with it enough (yet) to tell how it would look in more complex situation. For example, if you don’t want to name your repository method count, you need to override one more Pagy’s intenal method (it just assumes that it’s called count). But it’s totally doable.

One thing that looks promising are Pagy extras - for example a module to make pagination template part look nice in Boostrap. Hopefully, this part will grow and there will be more low-hanging fruits for those who decided to use Pagy in their Hanami application.

Please also note that there is alternative hanami-pagination gem available. It’s written by Anton Davydov, one of the core developers of Hanami.

Related posts