kaminariのページネーションのViewをActiveRecord以外のオレオレオブジェクトで実現する方法
動機
Railsのページネーションのライブラリとして有名なのがkaminariです。
kaminariはActiveRecord
やArray
には対応しています。
しかし、例えばデータアクセス層としてマイクロサービス化されたAPIを使っている場合、kaminariは対応していません。
それでもView層にてkaminariのnavタグを使いたいケースがあり、理想としては以下のように書きたいです。
<%= paginate @items %>
この記事では、paginate
関数に対応できるオブジェクトを独自で定義する方法について解説します。
ちなみに、このオブジェクトを便宜上オレオレオブジェクトと呼ぶことにします。
理屈
paginateに必要なインターフェース
paginate
の挙動を確認します。
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
する箇所に対してテストを仕込むと安心だと思います。
オレオレオブジェクトとなるクラスの実装例
どんなクラスを定義すれば良いかというと、kaminari
のPaginatableArray
というクラスの実装が参考になります。
kaminari-core/lib/kaminari/models/array_extension.rb
要点を以下に挙げます。
-
Array
を継承することによってeach
などにも対応している。 -
attr_internal_accessor :limit_value
が定義されている。 -
Kaminari::PageScopeMethods
をextend
している。
また、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
の実装を記載します。
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ページ目
2ページ目
3ページ目
4ページ目
サンプルコード
(kumackey/oreorekaminari)[https://github.com/kumackey/oreorekaminari]
あと書き
Railsでの実務経験は2,3週間くらいなので、変なコード書いてたら教えてください。
Discussion