⚡️

kaminariのページネーションのViewをActiveRecord以外のオレオレオブジェクトで実現する方法

2023/07/07に公開

動機

Railsのページネーションのライブラリとして有名なのがkaminariです。
kaminariはActiveRecordArrayには対応しています。
しかし、例えばデータアクセス層としてマイクロサービス化されたAPIを使っている場合、kaminariは対応していません。
それでもView層にてkaminariのnavタグを使いたいケースがあり、理想としては以下のように書きたいです。

<%= paginate @items %>

この記事では、paginate関数に対応できるオブジェクトを独自で定義する方法について解説します。
ちなみに、このオブジェクトを便宜上オレオレオブジェクトと呼ぶことにします。

理屈

paginateに必要なインターフェース

paginateの挙動を確認します。

kaminari-core/lib/kaminari/helpers/helper_methods.rb
      def paginate(scope, paginator_class: Kaminari::Helpers::Paginator, template: nil, **options)
        options[:total_pages] ||= scope.total_pages
        options.reverse_merge! current_page: scope.current_page, per_page: scope.limit_value, remote: false

        paginator = paginator_class.new (template || self), **options
        paginator.to_s
      end

kaminari-core/lib/kaminari/helpers/helper_methods.rb

paginateは第一引数としてscopeを受け取り、scopeに対して以下の3つのメソッドがあることを期待してます。

  • total_pages
  • current_page
  • limit_value

よって、このインターフェースを満たすオブジェクトを用意できれば、paginateの第一引数に渡すことが可能な、オレオレオブジェクトとなります。

ただし注意点

これはkaminariの仕様ではなく、実装に依存したやり方であることに注意が必要です。
例えば、kaminariのライブラリにアップデートがあり、メソッド名などの実装が変更された場合はエラーとなります。
もしこれを実施する場合はpaginateする箇所に対してテストを仕込むと安心だと思います。

オレオレオブジェクトとなるクラスの実装例

どんなクラスを定義すれば良いかというと、kaminariPaginatableArrayというクラスの実装が参考になります。

kaminari-core/lib/kaminari/models/array_extension.rb

要点を以下に挙げます。

  • Arrayを継承することによってeachなどにも対応している。
  • attr_internal_accessor :limit_valueが定義されている。
  • Kaminari::PageScopeMethodsextendしている。

また、extendするKaminari::PageScopeMethodsを見ると、以下の2つのメソッドが定義されていることが分かります。

kaminari-core/lib/kaminari/models/page_scope_methods.rb

  • current_page
  • total_pages

オレオレオブジェクトは、以上のような実装を参考に実装すれば良いということになります。

準備

# 最小限でrails new
rails new --minimal oreorekaminari

cd oreorekaminari

ruby -v                           
ruby 3.1.2p20 (2022-04-12 revision 4491bb740a) [arm64-darwin20]

rails -v
Rails 7.0.6

# ViewとControllerだけ作成
rails g controller items index
      create  app/controllers/items_controller.rb
       route  get 'items/index'
      invoke  erb
      create    app/views/items
      create    app/views/items/index.html.erb
      invoke  test_unit
      create    test/controllers/items_controller_test.rb
      invoke  helper
      create    app/helpers/items_helper.rb
      invoke    test_unit

# kaminariをGemfileに追加、インストール
echo "gem 'kaminari'" >> Gemfile
bundle

実装

以下にControllerのindexの実装を記載します。

app/controllers/items_controller.rb
class ItemsController < ApplicationController
  def index
    # page 1の場合、そもそもparams[:page]に値が来ないので、その場合1を使う。
    resp_body = api_call_with_pagination(params[:page] ? params[:page].to_i : 1)
    @items = PaginatableItems.new(resp_body)
  end
end

# このPaginatableItemsが、オレオレオブジェクト。kaminariのPaginatableArrayを参考に作ったクラス。paginateの第一引数に渡せる。
class PaginatableItems
  include Enumerable

  def initialize(body)
    @_body = body # ここでのbodyはapi_call_with_paginationの返り値のhashのようなものを想定。
  end

  def total_pages
    # e.g total_count 33, limit 10 -> total_pages: 4
    #     total_count 30, limit 10 -> total_pages: 3
    (@_body[:total_count].to_f / @_body[:limit]).ceil
  end

  def current_page
    @_body[:page]
  end

  def limit_value
    @_body[:limit]
  end

  # eachで回すので
  def each
    @_body[:results].each { |item| yield item }
  end
end

Item = Struct.new(:item_id)

def api_call_with_pagination(page)
  # 実際のAPIを用意するのは趣旨からズレるので、mockにする。
  # pageをrequest parameterに含み、以下のようなresponse bodyを返すAPIがあると仮定する。
  {
    total_count: 33, # total_pagesを計算する際に必要。total_pagesを返すでも可。
    page: page,
    limit: 10, # mock実装の都合上、10に固定。
    results: items_per_page(page)
  }
end

def items_per_page(page)
  case page
  when 1
    (1..10).map { |index| Item.new(index) }
  when 2
    (11..20).map { |index| Item.new(index) }
  when 3
    (21..30).map { |index| Item.new(index) }
  when 4
    (31..33).map { |index| Item.new(index) }
  else
    raise 'invalid page'
  end
end

directory構造を考えるのが面倒だったのでcontrollerに全て書きましたが、適宜ファイル分けはしてください。

動作確認

viewのコード

View側でページネーションを書きます。

<h1>Items#index</h1>
<p>Find me in app/views/items/index.html.erb</p>

<% @items.each do |item| %>
  <p><%= item %></p>
<% end %>

<%= paginate @items %>

結果

rails serverをターミナルに入力して、/items/index にアクセスしました。
無事にページネーションの見た目を実現できました。

1ページ目

1ページ目

2ページ目

2ページ目

3ページ目

3ページ目

4ページ目

4ページ目

サンプルコード

(kumackey/oreorekaminari)[https://github.com/kumackey/oreorekaminari]

あと書き

Railsでの実務経験は2,3週間くらいなので、変なコード書いてたら教えてください。

Discussion