ActiveRecord には心強い kaminari gem があるけど、 Ecto ではどうかな? と hex.pm で検索してみて、 scrivener というライブラリをみつけた。 scrivener_ecto とセットでよく流通しているらしい。これで遊んでみていた。ごくありふれたページネーションでもあんがい難しいもののようにおもわれて、不思議な気分のまま日記だけ書いておくことにする。
追記 (2022/10/30)
次の午後には考えを変えて、ここで書いたものを全部捨てるほうに傾いたことを書き直した。 https://jnsato.hateblo.jp/entry/2022/10/30/163000
バージョン
こんな感じ。
- elixir 1.14.1
- phoenix 1.6.15
- ecto 3.9.1
- scrivener_ecto 2.7.0
サンドボックス環境
Phoenix Introduction の Adding a Catalog Context の項目あたりを土台にする https://hexdocs.pm/phoenix/contexts.html#adding-a-catalog-context
言い換えると、 phx.new
したうえでこのコマンドで context とそのまわりのファイル一式をジェネレートしたところからやっていく。
mix phx.gen.html Catalog Product products
このときできる Catalog.list_products/1
が limit なしの全件取得クエリを発行するデザインになっているので、この関数にクエリと結果をページネーション化させてみる。
最初の形のスナップショット
最初はこんなふうだった。
defmodule Hello.Catalog do
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
@doc """
Returns the list of products.
## Examples
iex> list_products()
[%Product{}, ...]
"""
def list_products do
Repo.all(Product)
end
end
仕上がりのスナップショット
こんなふうになった。長いのは仕方ないとおもえば満足はいっている。
defmodule Hello.Repo
use Ecto.Repo,
otp_app: :hello,
adapter: Ecto.Adapters.Postgres
+
+ use Scrivener, page_size: 10
end
defmodule HelloWeb.ProductController do
use HelloWeb, :controller
alias Hello.Catalog
alias Hello.Catalog.Product
- def index(conn, _params) do
- products = Catalog.list_products()
- render(conn, "index.html", products: products)
+ def index(conn, params) do
+ %{
+ products: products,
+ metadata: metadata
+ } = Catalog.list_products(page: params["page"], products_per_page: params["per_page"])
+ render(conn, "index.html", products: products, metadata: metadata)
end
end
defmodule Hello.Catalog do
import Ecto.Query, warn: false
alias Hello.Repo
alias Hello.Catalog.Product
@default_page 1
@default_products_per_page 10
@max_products_per_page 100
@default_sort [desc: :id]
@sort_expressions_supported [desc: :id]
@doc """
Returns the map of paginated products.
## Examples
iex> list_products()
%{
products: [%Product{}, ...],
metadata: %{
page_number: 1,
page_size: 10,
total_entries: 25,
total_pages: 3,
sort_expressions_applied: [{:desc, :id}]
sort_expressions_supported: [{:desc, :id}]
}
}
"""
def list_products,
do: list_products(page: @default_page, products_per_page: @default_products_per_page)
# Accommodate indivisual parameter
def list_products(page: page),
do: list_products(page: page, products_per_page: nil)
def list_products(products_per_page: products_per_page),
do: list_products(page: nil, products_per_page: products_per_page)
# Accommodate nils.
def list_products(page: nil, products_per_page: nil),
do: list_products()
def list_products(page: nil, products_per_page: products_per_page) do
list_products(page: @default_page, products_per_page: products_per_page)
end
def list_products(page: page, products_per_page: nil) do
list_products(page: page, products_per_page: @default_products_per_page)
end
# Validate arbitrary page input. Mostly user input string but others too.
def list_products(page: page, products_per_page: products_per_page) when not is_integer(page) do
case Integer.parse(page) do
{int, ""} when int > 0 -> list_products(page: int, products_per_page: products_per_page)
_ -> list_products(page: @default_page, products_per_page: products_per_page)
end
end
# Validate arbitrary products_per_page input. Must define the max as well.
def list_products(page: page, products_per_page: products_per_page)
when not is_integer(products_per_page) do
case Integer.parse(products_per_page) do
{int, ""} when int > 0 and int <= @max_products_per_page ->
list_products(page: page, products_per_page: int)
_ ->
list_products(page: page, products_per_page: @default_products_per_page)
end
end
# Finally run query with normalized inputs.
def list_products(page: page, products_per_page: products_per_page) do
# %Scrivener.Page{}
page =
Product
|> order_by(^@default_sort)
|> Repo.paginate(page: page, page_size: products_per_page)
# Separate collection and the rest. Latter contains paging metadata.
{products, meta} = Map.pop(page, :entries)
# This is no longer compliant with %Scrivener.Page, so forget it.
meta =
Map.from_struct(meta)
# Then freely add some more metadata.
|> Map.put(:sort_expressions_applied, @default_sort)
|> Map.put(:sort_expressions_supported, @sort_expressions_supported)
# Is it better to declare custom struct to pattern match against?
# Not knowing a concise name let's just return bare map for now.
%{products: products, metadata: meta}
end
end
エクスキューズ
いつか読み返したときに「なんでこんなことやっているのか」と、新参者の気持ちを忘れた感想を持つこともあるかもとおもう。苦吟したところを思い出させてあげます。
入力のバリデーションを徹底的にやる
クエリパラメータで入力があたえられると、データは文字列としてパースされる。期待しているのは整数だが、けったいなデータが入力されないと信用してしまうのは禁物だ。で、入力のバリデーションをする。
実際のところは、バリデーション機構が scrivener_ecto に実装されている様子で、徹底するのはバカらしいような気もする。 scrivener の API を使ってたとえばこんなことをしてもエラーは起こらない。不正な入力は、デフォルトの設定にフォールバックする。
iex> Hello.Catalog.Product |> Hello.Repo.paginate(page: "2", page_size: "1")
iex> Hello.Catalog.Product |> Hello.Repo.paginate(page: nil, page_size: nil)
iex> Hello.Catalog.Product |> Hello.Repo.paginate(page: "foo", page_size: "bar")
でも、それってライブラリが働きすぎてしまっていないかな? という感覚が勝った。期待しているのがクエリの組み立てくらいであるときに、 Scrivener モジュールがあんまりたくさんの機能を肩代わりしてくれてしまうのは
- 乱暴に書いているのに「なぜか動いてしまう」のでかえって不安になる
- ソースを読みに行くと、マクロとメタプロでまだ読みがたい
- README の凡例は、ユーザー入力を Repo に直接注入していて、いかにもマスアサインメントを誘発するスタイルという感じがある
という動きをたどって、安心して使えるという信頼が持てなかった。上限を決めるオプションの設定が、 Repo にひとつだけしか定義できず、 Schema ごとに異なる定義を持てないのも柔軟でない感じがした。で、バリデーションは自分で書いてみることにした。
マスアサインメントへの防御は Rails であればコントローラで最初におこなうはずのもの、という感覚から、パラメータのデータをそのまま context に飛ばすのは躊躇した。とはいえ、いろんなコントローラのいろんなアクションでアドホックに書き込んでいくのは絶対に野暮なので、退けた。 context というのがどういうものかいまいち掴みきれていない感覚もあるけれど、ここ一箇所に実装が集約できるのであれば、まあまあ長くても悪くないようにおもう。
もし Ecto それ自体が、(たとえば Ecto.Changeset
のおかげで)マスアサインメントへの完全な免疫をもっているのであれば、この考えは間違っていて、 params をまるごと Repo に渡すスタイルでなんの問題もないことになりそう。それは調べていなかったけど、これを書いているうちに論理の穴に気づいたので、解き明かしてみる価値もある。
Scrivener.Page のデータ構造
こんな形になっている。はじめはなるほどとみていたが、すぐに扱いづらさにぶつかってしまった。
%Scrivener.Page{
entries: [%Hello.Catalog.Product{}],
page_number: 1,
page_size: 10,
total_entries: 1,
total_pages: 1
}
こういうデータ構造に書き換えて、すこし拡張もして使うことにした。
%{
products: [%Hello.Catalog.Product{}],
metadata: %{
page_number: 1,
page_size: 10,
total_entries: 1,
total_pages: 1,
sort_expressions_applied: [{:desc, :id}]
sort_expressions_supported: [{:desc, :id}]
}
}
フラットなキーが多く並ぶと、 context -> controller -> view に引数を渡していくときに、いちいちパターンマッチのリテラルがならんでボイラープレートが散らばった。いっぽうで、せっかくきれいなシンタックスが存在するときに、引数を動的に作るようなことをするのも不格好すぎるとった。トップレベルのキーの数を絞れば気持ちよくなりそうだったので、そうした。
ついでながら、ページネーションをおこなうということは、なんの順序に基づいてソートしているかという情報も不可分に含みそうだ。ソート順を表すパラメータを追加するように拡張して、ユーザーインターフェースに不足なく情報を引き渡せるようにしておく。
Ecto.Schema
を使うリソースに Product
と名前をつけているのに、そのリソースのセットを指すキーが :entries
とハードコーディングされているのもなんだか気に入らなかった。些細なことではあるけれど、たかだかいちライブラリのデータ構造を示唆する情報がインターフェースから漏れ出るのは嫌だった。 Scrivener
は Repo
とのコミュニケーションをしてくれれば十分だ。
書いたけど捨てたもの
クエリパラメータをパースして整数に変換するというタスクのために、 Plug を書いて controller に差し込むことも試した。それはこんなものだった。が、オプトイン形式の実装は漏れを生むに違いないし、 params
と conn.assign
にデータが二重化するし、全体的によくないデザインだとおもってやめた。
defmodule HelloWeb.Plugs.AssignPageParam do
@moduledoc """
A plug to get page parameter, parse it as int and assign it to the conn.
Leaving params intact is intentional. Mutating global params somewhere
in our plugs sounds uncool. On the other hand you must be aware where to
plug this module. You are expected to pinpoint the action and conditinally
use it. Otherwise you'll end up sprinke the useless assigns, which should
be design's fault. But let me try this here anyway.
## Examples
defmodule HelloController do
use HelloWeb, :controller
# Use it with guard. In controller.
plug HelloWeb.Plugs.AssignPageParam when action in [:index]
def index(conn, _params) do
# So you can safely navigate to integer instead of
# fetching string version of it from parameter
# and parse it everywhere.
conn.assigns.page
end
def show(conn, _params) do
# But in show you do not want it at all, right?
# conn.assigns.page
end
end
"""
import Plug.Conn
@default 1
def init(_), do: @default
def call(%Plug.Conn{params: %{"page" => page}} = conn, default) do
case Integer.parse(page) do
{int, ""} -> assign(conn, :page, int)
:error -> assign(conn, :page, default)
end
end
def call(conn, default), do: assign(conn, :page, default)
end
〆
はじめて kaminari を使ったときに、「すごい、よくわからないけど動いたぞ」と喜んだことは、そのころのアルバイト先の道玄坂のオフィスの景色と一緒に、わりとよく覚えている。いまとなってはそれを読めて、安心して使えることを知っている。なんなら原著者がどういう人かもわかる(生産者の顔写真つきの野菜みたいな理屈)。
いっぽうで、 scrivener はちょっと口にあわなかった。味の違いがわかるようになったという前向きな意味である。なにが合わないかを分析して、自分だったらどうするかと考えて作りを調整するのはエキサイティングだった。これをもうすこし一般化してモジュールにするという着想もあるのかもしれないが、長いといっても100行にも満たないくらいのコードであれば、一般化して詳細を隠蔽してしまうまでもなく、必要なだけ自分で作る(その気概を持つ)ことが第一であるとはおもう。