paging.ex (5001B)
1 # Zenflows is designed to implement the Valueflows vocabulary, 2 # written and maintained by srfsh <info@dyne.org>. 3 # Copyright (C) 2021-2022 Dyne.org foundation <foundation@dyne.org>. 4 # 5 # This program is free software: you can redistribute it and/or modify 6 # it under the terms of the GNU Affero General Public License as published by 7 # the Free Software Foundation, either version 3 of the License, or 8 # (at your option) any later version. 9 # 10 # This program is distributed in the hope that it will be useful, 11 # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 # GNU Affero General Public License for more details. 14 # 15 # You should have received a copy of the GNU Affero General Public License 16 # along with this program. If not, see <https://www.gnu.org/licenses/>. 17 18 defmodule Zenflows.GQL.Paging do 19 @moduledoc "Paging helpers for Resolv modules." 20 21 import Ecto.Query 22 23 alias Zenflows.DB.{ID, Repo, Schema} 24 25 @type result(schema) :: {:ok, t(schema)} | {:error, String.t()} 26 27 @type t(schema) :: %{ 28 page_info: page_info(), 29 edges: [edges(schema)], 30 } 31 32 @type page_info() :: %{ 33 start_cursor: ID.t() | nil, 34 end_cursor: ID.t() | nil, 35 has_previous_page: boolean(), 36 has_next_page: boolean(), 37 total_count: non_neg_integer(), 38 page_limit: non_neg_integer(), 39 } 40 41 @type edges(schema) :: %{ 42 cursor: ID.t(), 43 node: schema, 44 } 45 46 @type params() :: %{first: non_neg_integer(), after: ID.t()} 47 | %{last: non_neg_integer, before: ID.t()} 48 49 @spec def_page_size() :: non_neg_integer() 50 def def_page_size() do 51 conf()[:def_page_size] 52 end 53 54 @spec def_page_size() :: non_neg_integer() 55 def max_page_size() do 56 conf()[:max_page_size] 57 end 58 59 @spec conf() :: Keyword.t() 60 defp conf() do 61 Application.fetch_env!(:zenflows, Zenflows.GQL) 62 end 63 64 @spec parse(params()) :: {:error, String.t()} | {:ok, {:forw | :back, ID.t() | nil, non_neg_integer()}} 65 def parse(%{first: _, last: _}), do: {:error, "first and last can't be provided at the same time"} 66 def parse(%{after: _, before: _}), do: {:error, "after and before can't be provided at the same time"} 67 68 def parse(%{after: _, last: _}), do: {:error, "after and last can't be provided at the same time"} 69 def parse(%{before: _, first: _}), do: {:error, "before and first can't be provided at the same time"} 70 71 def parse(%{first: num}) when num < 0, do: {:error, "first must be positive"} 72 def parse(%{last: num}) when num < 0, do: {:error, "last must be positive"} 73 74 def parse(%{after: cur, first: num}), do: {:ok, {:forw, cur, normalize(num)}} 75 def parse(%{after: cur}), do: {:ok, {:forw, cur, def_page_size()}} 76 77 def parse(%{before: cur, last: num}), do: {:ok, {:back, cur, normalize(num)}} 78 def parse(%{before: cur}), do: {:ok, {:back, cur, def_page_size()}} 79 80 def parse(%{first: num}), do: {:ok, {:forw, nil, normalize(num)}} 81 def parse(%{last: num}), do: {:ok, {:back, nil, normalize(num)}} 82 83 def parse(_), do: {:ok, {:forw, nil, def_page_size()}} 84 85 @spec normalize(integer()) :: non_neg_integer() 86 defp normalize(num) do 87 # credo:disable-for-next-line Credo.Check.Refactor.MatchInCondition 88 if num > (max = max_page_size()), 89 do: max, 90 else: num 91 end 92 93 @doc """ 94 Page Ecto schemas. 95 96 Only supports forward or backward paging with or without cursors. 97 """ 98 @spec page(Schema.t() | Ecto.Query.t(), params()) :: result(Schema.t()) 99 def page(schema_or_query, params) do 100 with {:ok, {dir, cur, num}} <- parse(params) do 101 {page_fun, order_by} = 102 case dir do 103 :forw -> {&forw/3, [asc: :id]} 104 :back -> {&back/3, [desc: :id]} 105 _ -> raise ArgumentError # impossible 106 end 107 where = 108 case {dir, cur} do 109 {_, nil} -> [] 110 {:forw, cur} -> dynamic([s], s.id > ^cur) 111 {:back, cur} -> dynamic([s], s.id < ^cur) 112 _ -> raise ArgumentError # impossible 113 end 114 {:ok, 115 from(s in schema_or_query, 116 where: ^where, 117 order_by: ^order_by, 118 limit: ^num + 1, 119 select: %{cursor: s.id, node: s}) 120 |> Repo.all() 121 |> page_fun.(cur, num)} 122 end 123 end 124 125 @spec forw(edges(Schema.t()), ID.t() | nil, non_neg_integer()) :: t(Schema.t()) 126 def forw(edges, cur, num) do 127 {edges, count} = 128 Enum.reduce(edges, {[], 0}, fn e, {edges, count} -> 129 {[e | edges], count + 1} 130 end) 131 132 {edges, has_next?, count} = 133 # we indeed have fetched num+1 records 134 if count - 1 == num do 135 [_ | edges] = edges 136 {edges, true, count - 1} 137 else 138 {edges, false, count} 139 end 140 141 {edges, first, last} = 142 case edges do 143 [] -> {[], nil, nil} 144 _ -> 145 [last | _] = edges 146 [first | _] = edges = Enum.reverse(edges) 147 {edges, first, last} 148 end 149 150 %{ 151 edges: edges, 152 page_info: %{ 153 start_cursor: first[:cursor], 154 end_cursor: last[:cursor], 155 has_next_page: has_next?, 156 has_previous_page: cur != nil, 157 total_count: count, 158 page_limit: num, 159 }, 160 } 161 end 162 163 @spec back(edges(Schema.t()), ID.t() | nil, non_neg_integer()) :: t(Schema.t()) 164 def back(edges, cur, num) do 165 # Currently, this part of the algorithm doesn't care about 166 # whether we do forward or backward paging. 167 forw(edges, cur, num) 168 end 169 end