diff --git a/lib/scrivener/paginater/ecto/query.ex b/lib/scrivener/paginater/ecto/query.ex index abbb2fb..65594c4 100644 --- a/lib/scrivener/paginater/ecto/query.ex +++ b/lib/scrivener/paginater/ecto/query.ex @@ -1,16 +1,18 @@ defimpl Scrivener.Paginater, for: Ecto.Query do import Ecto.Query - alias Scrivener.{Config, Page} + alias Scrivener.{Config, Page, SimplePage} @moduledoc false - @spec paginate(Ecto.Query.t(), Scrivener.Config.t()) :: Scrivener.Page.t() + @spec paginate(Ecto.Query.t(), Scrivener.Config.t()) :: + Scrivener.Page.t() | Scrivener.SimplePage.t() def paginate(query, %Config{ - page_size: page_size, - page_number: page_number, + page_type: :normal, module: repo, caller: caller, + page_number: page_number, + page_size: page_size, options: options }) do total_entries = @@ -26,25 +28,59 @@ defimpl Scrivener.Paginater, for: Ecto.Query do page_number = if allow_overflow_page_number, do: page_number, else: min(total_pages, page_number) + entries = + if page_number > total_pages, + do: [], + else: entries(query, repo, page_number, page_size, options) + %Page{ page_size: page_size, page_number: page_number, - entries: entries(query, repo, page_number, total_pages, page_size, options), + entries: entries, total_entries: total_entries, total_pages: total_pages } end - defp entries(_query, _repo, page_number, total_pages, _page_size, _options) - when page_number > total_pages, - do: [] + def paginate(query, %Config{ + page_type: :simple, + module: repo, + page_number: page_number, + page_size: page_size, + options: options + }) do + entries_with_maybe_one_extra = + entries(query, repo, page_number, page_size, options, extra_entry_size: 1) + + {entries, has_more} = + if length(entries_with_maybe_one_extra) > page_size do + entries = + entries_with_maybe_one_extra + |> Enum.reverse() + |> tl() + |> Enum.reverse() + + {entries, true} + else + {entries_with_maybe_one_extra, false} + end + + %SimplePage{ + page_size: page_size, + page_number: page_number, + entries: entries, + has_more: has_more + } + end - defp entries(query, repo, page_number, _total_pages, page_size, options) do + defp entries(query, repo, page_number, page_size, options, opts \\ []) do + extra_entry_size = Keyword.get(opts, :extra_entry_size, 0) offset = Keyword.get_lazy(options, :offset, fn -> page_size * (page_number - 1) end) + limit = page_size + extra_entry_size query |> offset(^offset) - |> limit(^page_size) + |> limit(^limit) |> repo.all(options) end diff --git a/mix.exs b/mix.exs index d2ea12e..9eb972c 100644 --- a/mix.exs +++ b/mix.exs @@ -38,7 +38,8 @@ defmodule Scrivener.Ecto.Mixfile do defp deps do [ - {:scrivener, "~> 2.4"}, + {:scrivener, + github: "RiverFinancial/scrivener", ref: "e21e9e83b4e101d2e3b5e24acb29289708746dac"}, {:ecto, "~> 3.12"}, {:ecto_sql, "~> 3.12", only: :test}, {:dialyxir, "~> 1.0", only: :dev}, diff --git a/mix.lock b/mix.lock index 53dd3d9..a333894 100644 --- a/mix.lock +++ b/mix.lock @@ -14,6 +14,6 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "postgrex": {:hex, :postgrex, "0.19.1", "73b498508b69aded53907fe48a1fee811be34cc720e69ef4ccd568c8715495ea", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8bac7885a18f381e091ec6caf41bda7bb8c77912bb0e9285212829afe5d8a8f8"}, - "scrivener": {:hex, :scrivener, "2.7.2", "1d913c965ec352650a7f864ad7fd8d80462f76a32f33d57d1e48bc5e9d40aba2", [:mix], [], "hexpm", "7866a0ec4d40274efbee1db8bead13a995ea4926ecd8203345af8f90d2b620d9"}, + "scrivener": {:git, "https://github.com/RiverFinancial/scrivener.git", "e21e9e83b4e101d2e3b5e24acb29289708746dac", [ref: "e21e9e83b4e101d2e3b5e24acb29289708746dac"]}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, } diff --git a/test/scrivener/paginator/ecto/query_test.exs b/test/scrivener/paginator/ecto/query_test.exs index 671eecf..52ff7f2 100644 --- a/test/scrivener/paginator/ecto/query_test.exs +++ b/test/scrivener/paginator/ecto/query_test.exs @@ -49,7 +49,7 @@ defmodule Scrivener.Paginator.Ecto.QueryTest do end) end - describe "paginate" do + describe "paginate with normal page type" do test "paginates an unconstrained query" do create_posts() @@ -461,6 +461,500 @@ defmodule Scrivener.Paginator.Ecto.QueryTest do end end + describe "paginate with simple page type" do + test "paginates the same as normal page type" do + create_posts() + + # There are total 8 posts, so we should have 4 pages + page_size = 2 + + for page_number <- 1..4 do + simple_page = + Post + |> Scrivener.Ecto.Repo.paginate( + page_type: :simple, + page_size: page_size, + page_number: page_number + ) + + normal_page = + Post + |> Scrivener.Ecto.Repo.paginate( + page_type: :normal, + page_size: page_size, + page_number: page_number + ) + + assert simple_page.page_size == normal_page.page_size + assert simple_page.page_number == normal_page.page_number + assert simple_page.entries == normal_page.entries + assert simple_page.has_more == normal_page.total_pages >= page_number + end + end + + test "paginates an unconstrained query" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = Post |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "page information is correct with no results" do + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: false + } = Post |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "uses defaults from the repo" do + posts = create_posts() + + expected_entries = Enum.take(posts, 5) + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + entries: ^expected_entries, + has_more: true + } = Post |> Post.published() |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "it handles preloads" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> Post.published() + |> preload(:comments) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "it handles offsets" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: false + } = + Post + |> Post.unpublished() + |> Scrivener.Ecto.Repo.paginate(options: [offset: 1], page_type: :simple) + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: false + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate(options: [offset: 2], page_type: :simple) + end + + test "it handles complex selects" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> join(:left, [p], c in assoc(p, :comments)) + |> group_by([p], p.id) + |> select([p], sum(p.id)) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "it handles complex order_by" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> select([p], fragment("? as aliased_title", p.title)) + |> order_by([p], fragment("aliased_title")) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "can be provided the current page and page size as a params map" do + posts = create_posts() + expected_entries = Enum.drop(posts, 3) + + assert %Scrivener.SimplePage{ + page_size: 3, + page_number: 2, + entries: ^expected_entries, + has_more: false + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate(%{ + "page" => "2", + "page_size" => "3", + "page_type" => :simple + }) + end + + test "can be provided the current page and page size as options" do + posts = create_posts() + + expected_entries = Enum.drop(posts, 3) + + assert %Scrivener.SimplePage{ + page_size: 3, + page_number: 2, + entries: ^expected_entries, + has_more: false + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate(page: 2, page_size: 3, page_type: :simple) + end + + test "can be provided the caller as options" do + create_posts() + parent = self() + + task = + Task.async(fn -> + Post + |> Scrivener.Ecto.Repo.paginate(caller: parent, page_type: :simple) + end) + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = Task.await(task) + end + + test "can be provided the caller as a map" do + create_posts() + + parent = self() + + task = + Task.async(fn -> + Post + |> Scrivener.Ecto.Repo.paginate(%{"caller" => parent, "page_type" => :simple}) + end) + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = Task.await(task) + end + + test "will respect the max_page_size configuration" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 10 + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate(%{ + "page" => "1", + "page_size" => "20", + "page_type" => :simple + }) + end + + test "will ignore total_entries passed to paginate" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate(options: [total_entries: 130], page_type: :simple) + end + + test "will return an empty list if page_numer is too large" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 3, + has_more: false, + entries: [] + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate(page: 3, page_type: :simple) + end + + test "allows overflow page numbers if option is specified" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 3, + has_more: false, + entries: [] + } = + Post + |> Post.published() + |> Scrivener.Ecto.Repo.paginate( + page: 3, + options: [allow_overflow_page_number: true], + page_type: :simple + ) + end + + test "can be used on a table with any primary key" do + create_key_values() + + assert %Scrivener.SimplePage{ + page_size: 2, + page_number: 1, + has_more: true + } = + KeyValue + |> KeyValue.zero() + |> Scrivener.Ecto.Repo.paginate(page_size: 2, page_type: :simple) + end + + test "can be used with a group by clause" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> join(:left, [p], c in assoc(p, :comments)) + |> group_by([p], p.id) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "can be used with a group by clause on field other than id" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> group_by([p], p.body) + |> select([p], p.body) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "can be used with a group by clause on field on joined table" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + entries: entries, + page_number: 1, + has_more: false + } = + Post + |> join(:inner, [p], c in assoc(p, :comments)) + |> group_by([p, c], c.body) + |> select([p, c], {c.body, count("*")}) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + + assert length(entries) == 2 + end + + test "can be used with compound group by clause" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + entries: entries, + has_more: false + } = + Post + |> join(:inner, [p], c in assoc(p, :comments)) + |> group_by([p, c], [c.body, p.title]) + |> select([p, c], {c.body, p.title, count("*")}) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + + assert length(entries) == 2 + end + + test "can be used with combinations" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> Post.published() + |> union(^Post.unpublished(Post)) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "can be provided a Scrivener.Config directly" do + posts = create_posts() + + expected_entries = Enum.drop(posts, 4) + + config = %Scrivener.Config{ + module: Scrivener.Ecto.Repo, + page_number: 2, + page_size: 4, + page_type: :simple, + options: [] + } + + assert %Scrivener.SimplePage{ + page_size: 4, + page_number: 2, + entries: ^expected_entries, + has_more: false + } = + Post + |> Post.published() + |> Scrivener.paginate(config) + end + + test "can be provided a keyword directly" do + posts = create_posts() + expected_entries = Enum.drop(posts, 4) + + assert %Scrivener.SimplePage{ + page_size: 4, + page_number: 2, + entries: ^expected_entries, + has_more: false + } = + Post + |> Post.published() + |> Scrivener.paginate( + module: Scrivener.Ecto.Repo, + page: 2, + page_size: 4, + page_type: :simple + ) + end + + test "can be provided a map directly" do + posts = create_posts() + expected_entries = Enum.drop(posts, 4) + + assert %Scrivener.SimplePage{ + page_size: 4, + page_number: 2, + entries: ^expected_entries, + has_more: false + } = + Post + |> Post.published() + |> Scrivener.paginate(%{ + "module" => Scrivener.Ecto.Repo, + "page" => 2, + "page_size" => 4, + "page_type" => :simple + }) + end + + test "pagination plays nice with distinct on in the query" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> distinct([p], asc: p.title, asc: p.inserted_at) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "pagination plays nice with absolute distinct in the query" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> distinct(true) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "pagination plays nice with a singular distinct in the query" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Post + |> distinct(:id) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + end + + test "pagination plays nice with absolute distinct on a join query" do + create_posts() + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + entries: entries, + has_more: false + } = + Post + |> distinct(true) + |> join(:inner, [p], c in assoc(p, :comments)) + |> Scrivener.Ecto.Repo.paginate(page_type: :simple) + + assert length(entries) == 1 + end + + test "can specify prefix" do + create_users(6, "tenant_1") + create_users(2, "tenant_2") + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: true + } = + Scrivener.Ecto.Repo.paginate(User, + options: [prefix: "tenant_1"], + page_type: :simple + ) + + assert %Scrivener.SimplePage{ + page_size: 5, + page_number: 1, + has_more: false + } = + Scrivener.Ecto.Repo.paginate(User, + options: [prefix: "tenant_2"], + page_type: :simple + ) + end + end + test "accepts repo options" do log = capture_log(fn -> Scrivener.Ecto.Repo.paginate(Post, options: [log: true]) end)