Progaku-copy / progaku-archive

Progakuのアーカイブ用のアプリ
0 stars 1 forks source link

Memoの一覧APIにページネーションを付与する #76

Closed kakeru-one closed 1 month ago

kakeru-one commented 3 months ago

ゴール

参考

kuri0616 commented 2 months ago

ページネーション設計

不明点

ページの計算方法

OFFSETには何件目から取得するかを指定する 例えばOFFSETに20を指定すれば21件目から表示することになる LIMITにはOFFSETで指定した場所から何件取得するのか SQLの例(21件目から取得する場合)

SELECT *
FROM memos
LIMIT 10 OFFSET 20;

ページ計算 (page - 1) * limit limitは定数にする LIMIT_COUNT = 10 pageはクエリパラメータで取得する これでOFFSETは計算できる。

kuri0616 commented 2 months ago

railsでoffsetのsqlを発行するには??

offsetメソッド https://api.rubyonrails.org/v7.2/classes/ActiveRecord/QueryMethods.html#method-i-offset:~:text=offset(value),before%20returning%20rows.

kakeru-one commented 2 months ago

@kuri0616

何故、OFFSETページネーションは遅いのか

この記事読むといいですよ〜! https://use-the-index-luke.com/ja/sql/partial-results/fetch-next-page

kuri0616 commented 2 months ago

railsでlimiのsqlを発行するには?

limitメソッド https://api.rubyonrails.org/v7.2/classes/ActiveRecord/QueryMethods.html#method-i-limit:~:text=limit(value),records%20to%20retrieve.

kuri0616 commented 2 months ago

@ochi-sho-private-study ありがとうございます! 読んでみます!

kuri0616 commented 2 months ago

仕様

全体のメモ件数から算出した合計ページ数をレスポンスに含める必要がある COUNTで行数を取得して計算 フロント側でページネーションの表示に必要

COUNT()OVER()でデータと総件数を同時に取得できるらしいが、フルスキャンになってめちゃくちゃパフォーマンスが落ちるらしいから、別クエリの方が良い???

https://raahii.me/posts/count-over-query-is-slow/

kuri0616 commented 2 months ago

@ochi-sho-private-study 読みました〜 今回、IDでソートしているからシーク法使えるんじゃないかと思ったんですけど、検索条件によって昇順、降順変わるし、実装複雑になりそうですね😭笑 メモアプリは長期間運用しても総レコード数大したことないと思うので、おとなしくOFFSETにします😭

kuri0616 commented 2 months ago

@ochi-sho-private-study 1点、相談したいのですが ページネーション機能は、別にページネーションモジュールを作成して、Queryモジュールで検索された結果に対して、行う感じで考えているのですが、どうでしょうか?

def index
  memos = Memo::Query.resolve(memos: Memo.all, params: params)
  paginated_memos = Pagination.apply(scope: memos, params: params)
  render json: { memos: paginated_memos }, status: :ok
end

イメージとしてはこんな感じで呼び出す感じでと思っているのですが。 あくまでも検索とページネーションは別の機能なので単一責任の原則という点で考えても切り分けた方が良いかなと思ったからです。あとは。indexアクションみた時も1行毎に何の処理をしているのかわかりやすいかなと。 上記の方法だとクエリ発行の回数が増えたり、するのでしょうか? ActiveRecordは遅延評価と聞いたので、検索条件にLIMITとOFFSETを含めて1回のクエリで発行してくれるんですかね? この辺あまり理解できておらず・・

kakeru-one commented 2 months ago

@kuri0616

ページネーション機能は、別にページネーションモジュールを作成して、Queryモジュールで検索された結果に対して、行う感じで考えているのですが、どうでしょうか?

それでもいいと思いますが、 個人的にこの程度の役割ならまとめてしまっていいと思っていて(pagenationもクエリを発行するため、Queryと言える)、 一応以下のように実装すると、MemoQueryを呼び出すだけで良くなりそうです。 (pagenateする前のcountが必要なければ、FILTERSにPageFilterを入れられるので、もっと記述が簡単になりそう。)

PageFilter

module PageFilter
  MAX_ITEMS = 10.0
  public_constant :MAX_ITEMS
  FIRST_PAGE = 1
  private_constant :FIRST_PAGE

  class << self
    def resolve(scope:, params:)
      return scope.limit(MAX_ITEMS) if params[:page].blank?

      # 整数と文字列数値以外はエラーとする
      target_page = Integer(params[:page], 10, exception: false) || params[:page]
      raise TypeError unless target_page.is_a?(Integer)

      [target_page - 1, 0].max.then do |page|
        scope.offset(MAX_ITEMS * page).limit(MAX_ITEMS)
      end
    end
  end
end

Query

module MemoQuery
  FILTERS = [
    TitleFilter,
    ContentFilter,
    OrderFilter
  ].freeze
  private_constant :FILTERS

  class << self
    def call(filter_collection:, params:)
      memo_relation = \
        filtered_memos(
          filter_collection: filter_collection,
          params: params
        )
      count = memo_relation.count
      [
        PageFilter.resolve(scope: memo_relation, params: params),
        count,
        count.zero? ? 1 : (memo_relation.count / PageFilter::MAX_ITEMS).ceil,
        page_number(params[:page])
      ]
    end

    private

    def filtered_memos(filter_collection:, params:)
      FILTERS.reduce(filter_collection) do |scope, filter|
        filter.resolve(scope: scope, params: params)
      end
    end

    def page_number(page)
      return Integer(page) if page.present?

      1
    rescue ArgumentError
      1
    end
  end
end

上記の方法だとクエリ発行の回数が増えたり、するのでしょうか? ActiveRecordは遅延評価と聞いたので、検索条件にLIMITとOFFSETを含めて1回のクエリで発行してくれるんですかね? この辺あまり理解できておらず・・

これは試してみたらいいと思います! (LIMITとOFFSETを含めて1回のクエリで発行してくれるで合ってるはず!)

kuri0616 commented 2 months ago

@ochi-sho-private-study 実装例ありがとうございます!! 大変、勉強になります!こんな方法は、全く持って思いつきませんでした😂笑

1つ、質問なのですが! こちらintegerメソッドでは例外を発生しないようにして手動でintegerかチェックしてTypeErrorを発生させるようにしている理由は何故ですか?? 変換できない時に発生させられるデフォルトのArgumentErrorでは何か問題がある感じなんですかね??

target_page = Integer(params[:page], 10, exception: false) || params[:page]
raise TypeError unless target_page.is_a?(Integer)
[target_page - 1, 0].max.then do |page|
  scope.offset(MAX_ITEMS * page).limit(MAX_ITEMS)
 end

配列で最小値持たせて、maxで0とることで負の数ならないようにするアプローチも色々な場面で応用できそうで学びなります

こういうのってどうやって考えるんですかね?😭笑

kakeru-one commented 1 month ago

@kuri0616

こちらintegerメソッドでは例外を発生しないようにして手動でintegerかチェックしてTypeErrorを発生させるようにしている理由は何故ですか??

integerメソッドでは例外を発生しないようにしているのは、 params[:page]が整数だったときにArgumentErrorがraiseしないようにするためです!

irb(main):014:0> Integer(2, 10)
Traceback (most recent call last):
        5: from /usr/bin/irb:23:in `<main>'
        4: from /usr/bin/irb:23:in `load'
        3: from /Library/Ruby/Gems/2.6.0/gems/irb-1.0.0/exe/irb:11:in `<top (required)>'
        2: from (irb):14
        1: from (irb):14:in `Integer'
ArgumentError (base specified for non string value)

こういうのってどうやって考えるんですかね?😭笑

実務のコード読んだり、OSS読んだりすると思いつくようになると思います!

Reference①: https://zenn.dev/takahashim/articles/ac725fb16ec7a11809c5 Reference②: https://github.com/dodonki1223/rails_oss Reference③: https://zenn.dev/kitabatake/books/learn-from-devto-data-update-script