diff --git a/lib/paginator.ex b/lib/paginator.ex index 8370d53..3811246 100644 --- a/lib/paginator.ex +++ b/lib/paginator.ex @@ -88,6 +88,9 @@ defmodule Paginator do * `:total_count_limit` - Running count queries on tables with a large number of records is expensive so it is capped by default. Can be set to `:infinity` in order to count all the records. Defaults to `10,000`. + * `:page_booleans` - populates `:has_next_page` and `:has_previous_page` booleans. + Always returns an `:after` and `:before` cursor (if available). This mimics + [relay style pagination](https://relay.dev/graphql/connections.htm#sec-undefined.PageInfo) ## Repo options @@ -187,7 +190,9 @@ defmodule Paginator do after: after_cursor(paginated_entries, sorted_entries, config), limit: config.limit, total_count: total_count, - total_count_cap_exceeded: total_count_cap_exceeded + total_count_cap_exceeded: total_count_cap_exceeded, + has_next_page: has_next_page(paginated_entries, sorted_entries, config), + has_previous_page: has_previous_page(paginated_entries, sorted_entries, config), } } end @@ -250,6 +255,11 @@ defmodule Paginator do Map.get(schema, field) end + defp before_cursor([], [], %Config{before: c_before, page_booleans: true}) + when not is_nil(c_before) do + c_before + end + defp before_cursor([], [], _config), do: nil defp before_cursor(_paginated_entries, _sorted_entries, %Config{after: nil, before: nil}), @@ -260,6 +270,10 @@ defmodule Paginator do first_or_nil(paginated_entries, config) end + defp before_cursor(paginated_entries, _sorted_entries, %Config{page_booleans: true} = config) do + first_or_nil(paginated_entries, config) + end + defp before_cursor(paginated_entries, sorted_entries, config) do if first_page?(sorted_entries, config) do nil @@ -276,6 +290,11 @@ defmodule Paginator do end end + defp after_cursor([], [], %Config{after: c_after, page_booleans: true}) + when not is_nil(c_after) do + c_after + end + defp after_cursor([], [], _config), do: nil defp after_cursor(paginated_entries, _sorted_entries, %Config{before: c_before} = config) @@ -283,6 +302,10 @@ defmodule Paginator do last_or_nil(paginated_entries, config) end + defp after_cursor(paginated_entries, _sorted_entries, %Config{page_booleans: true} = config) do + last_or_nil(paginated_entries, config) + end + defp after_cursor(paginated_entries, sorted_entries, config) do if last_page?(sorted_entries, config) do nil @@ -299,6 +322,44 @@ defmodule Paginator do end end + defp has_next_page(_paginated_entries, _sorted_entries, %Config{page_booleans: false}) do + nil + end + + defp has_next_page([], [], _config) do + false + end + + defp has_next_page(_paginated_entries, _sorted_entries, %Config{before: c_before}) + when not is_nil(c_before) do + true + end + + defp has_next_page(_paginated_entries, sorted_entries, config) do + !last_page?(sorted_entries, config) + end + + defp has_previous_page(_paginated_entries, _sorted_entries, %Config{page_booleans: false}) do + nil + end + + defp has_previous_page([], [], _config) do + false + end + + defp has_previous_page(_paginated_entries, _sorted_entries, %Config{after: nil, before: nil}) do + false + end + + defp has_previous_page(_paginated_entries, _sorted_entries, %Config{after: c_after}) + when not is_nil(c_after) do + true + end + + defp has_previous_page(_paginated_entries, sorted_entries, config) do + !first_page?(sorted_entries, config) + end + defp fetch_cursor_value(schema, %Config{ cursor_fields: cursor_fields, fetch_cursor_value_fun: fetch_cursor_value_fun diff --git a/lib/paginator/config.ex b/lib/paginator/config.ex index 8116084..196dff0 100644 --- a/lib/paginator/config.ex +++ b/lib/paginator/config.ex @@ -17,7 +17,8 @@ defmodule Paginator.Config do :limit, :maximum_limit, :sort_direction, - :total_count_limit + :total_count_limit, + :page_booleans ] @default_total_count_primary_key_field :id @@ -48,7 +49,8 @@ defmodule Paginator.Config do opts[:total_count_primary_key_field] || @default_total_count_primary_key_field, limit: limit(opts), sort_direction: opts[:sort_direction], - total_count_limit: opts[:total_count_limit] || @default_total_count_limit + total_count_limit: opts[:total_count_limit] || @default_total_count_limit, + page_booleans: opts[:page_booleans] || false } |> convert_deprecated_config() end diff --git a/lib/paginator/page/metadata.ex b/lib/paginator/page/metadata.ex index d4ccad2..56290d0 100644 --- a/lib/paginator/page/metadata.ex +++ b/lib/paginator/page/metadata.ex @@ -19,8 +19,10 @@ defmodule Paginator.Page.Metadata do before: opaque_cursor() | nil, limit: integer(), total_count: integer() | nil, - total_count_cap_exceeded: boolean() | nil + total_count_cap_exceeded: boolean() | nil, + has_next_page: boolean() | nil, + has_previous_page: boolean() | nil } - defstruct [:after, :before, :limit, :total_count, :total_count_cap_exceeded] + defstruct [:after, :before, :limit, :total_count, :total_count_cap_exceeded, :has_next_page, :has_previous_page] end diff --git a/test/paginator_test.exs b/test/paginator_test.exs index 7f4c316..c840ee6 100644 --- a/test/paginator_test.exs +++ b/test/paginator_test.exs @@ -26,6 +26,40 @@ defmodule PaginatorTest do assert page.metadata.after == nil end + test "paginates forward with page_booleans", %{ + payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} + } do + opts = [cursor_fields: [:charged_at, :id], sort_direction: :asc, limit: 4, page_booleans: true] + + page = payments_by_charged_at() |> Repo.paginate(opts) + assert to_ids(page.entries) == to_ids([p5, p4, p1, p6]) + assert page.metadata.before == nil + assert page.metadata.after == encode_cursor(%{charged_at: p6.charged_at, id: p6.id}) + assert page.metadata.has_previous_page == false + assert page.metadata.has_next_page == true + + page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: page.metadata.after]) + assert to_ids(page.entries) == to_ids([p7, p3, p10, p2]) + assert page.metadata.before == encode_cursor(%{charged_at: p7.charged_at, id: p7.id}) + assert page.metadata.after == encode_cursor(%{charged_at: p2.charged_at, id: p2.id}) + assert page.metadata.has_next_page == true + assert page.metadata.has_previous_page == true + + page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: page.metadata.after]) + assert to_ids(page.entries) == to_ids([p12, p8, p9, p11]) + assert page.metadata.before == encode_cursor(%{charged_at: p12.charged_at, id: p12.id}) + assert page.metadata.after == encode_cursor(%{charged_at: p11.charged_at, id: p11.id}) + assert page.metadata.has_next_page == false + assert page.metadata.has_previous_page == true + + page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: page.metadata.after]) + assert to_ids(page.entries) == to_ids([]) + assert page.metadata.before == nil + assert page.metadata.after == encode_cursor(%{charged_at: p11.charged_at, id: p11.id}) + assert page.metadata.has_next_page == false + assert page.metadata.has_previous_page == false + end + test "paginates forward with legacy cursor", %{ payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} } do @@ -74,6 +108,43 @@ defmodule PaginatorTest do assert page.metadata.before == nil end + test "paginates backward with page_booleans", %{ + payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} + } do + opts = [cursor_fields: [:charged_at, :id], sort_direction: :asc, limit: 4, page_booleans: true] + + page = + payments_by_charged_at() + |> Repo.paginate(opts ++ [before: encode_cursor(%{charged_at: p11.charged_at, id: p11.id})]) + + assert to_ids(page.entries) == to_ids([p2, p12, p8, p9]) + assert page.metadata.before == encode_cursor(%{charged_at: p2.charged_at, id: p2.id}) + assert page.metadata.after == encode_cursor(%{charged_at: p9.charged_at, id: p9.id}) + assert page.metadata.has_next_page == true + assert page.metadata.has_previous_page == true + + page = payments_by_charged_at() |> Repo.paginate(opts ++ [before: page.metadata.before]) + assert to_ids(page.entries) == to_ids([p6, p7, p3, p10]) + assert page.metadata.before == encode_cursor(%{charged_at: p6.charged_at, id: p6.id}) + assert page.metadata.after == encode_cursor(%{charged_at: p10.charged_at, id: p10.id}) + assert page.metadata.has_next_page == true + assert page.metadata.has_previous_page == true + + page = payments_by_charged_at() |> Repo.paginate(opts ++ [before: page.metadata.before]) + assert to_ids(page.entries) == to_ids([p5, p4, p1]) + assert page.metadata.before == encode_cursor(%{charged_at: p5.charged_at, id: p5.id}) + assert page.metadata.after == encode_cursor(%{charged_at: p1.charged_at, id: p1.id}) + assert page.metadata.has_next_page == true + assert page.metadata.has_previous_page == false + + page = payments_by_charged_at() |> Repo.paginate(opts ++ [before: page.metadata.before]) + assert to_ids(page.entries) == to_ids([]) + assert page.metadata.before == encode_cursor(%{charged_at: p5.charged_at, id: p5.id}) + assert page.metadata.after == nil + assert page.metadata.has_next_page == false + assert page.metadata.has_previous_page == false + end + test "returns an empty page when there are no results" do page = payments_by_status("failed") @@ -84,6 +155,18 @@ defmodule PaginatorTest do assert page.metadata.before == nil end + test "returns an empty page when there are no results with page_booleans" do + page = + payments_by_status("failed") + |> Repo.paginate(cursor_fields: [:charged_at, :id], limit: 10, page_booleans: true) + + assert page.entries == [] + assert page.metadata.after == nil + assert page.metadata.before == nil + assert page.metadata.has_next_page == false + assert page.metadata.has_previous_page == false + end + describe "paginate a collection of payments, sorting by charged_at" do test "sorts ascending without cursors", %{ payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12}