ユユユユユ

webエンジニアです

scrivener_ecto

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 とハードコーディングされているのもなんだか気に入らなかった。些細なことではあるけれど、たかだかいちライブラリのデータ構造を示唆する情報がインターフェースから漏れ出るのは嫌だった。 ScrivenerRepo とのコミュニケーションをしてくれれば十分だ。

書いたけど捨てたもの

クエリパラメータをパースして整数に変換するというタスクのために、 Plug を書いて controller に差し込むことも試した。それはこんなものだった。が、オプトイン形式の実装は漏れを生むに違いないし、 paramsconn.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行にも満たないくらいのコードであれば、一般化して詳細を隠蔽してしまうまでもなく、必要なだけ自分で作る(その気概を持つ)ことが第一であるとはおもう。