paging.ex (4759B)
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.DB.Paging do 19 @moduledoc "Paging helpers for Domain modules." 20 21 import Ecto.Query 22 23 alias Zenflows.DB.{ID, Repo} 24 25 @type result() :: {:ok, t()} | {:error, String.t()} 26 27 @type t() :: %{ 28 page_info: page_info(), 29 edges: [edges()], 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() :: %{ 42 cursor: ID.t(), 43 node: struct(), 44 } 45 46 @type params() :: %{atom() => term()} 47 48 @spec def_page_size() :: non_neg_integer() 49 def def_page_size() do 50 conf()[:def_page_size] 51 end 52 53 @spec max_page_size() :: non_neg_integer() 54 def max_page_size() do 55 conf()[:max_page_size] 56 end 57 58 @spec conf() :: Keyword.t() 59 defp conf() do 60 Application.fetch_env!(:zenflows, Zenflows.GQL) 61 end 62 63 @spec parse(params()) :: {:error, String.t()} | {:ok, {:forw | :back, ID.t() | nil, non_neg_integer()}} 64 def parse(%{first: _, last: _}), do: {:error, "first and last can't be provided at the same time"} 65 def parse(%{after: _, before: _}), do: {:error, "after and before can't be provided at the same time"} 66 67 def parse(%{after: _, last: _}), do: {:error, "after and last can't be provided at the same time"} 68 def parse(%{before: _, first: _}), do: {:error, "before and first can't be provided at the same time"} 69 70 def parse(%{first: num}) when num < 0, do: {:error, "first must be positive"} 71 def parse(%{last: num}) when num < 0, do: {:error, "last must be positive"} 72 73 def parse(%{after: cur, first: num}), do: {:ok, {:forw, cur, normalize(num)}} 74 def parse(%{after: cur}), do: {:ok, {:forw, cur, def_page_size()}} 75 76 def parse(%{before: cur, last: num}), do: {:ok, {:back, cur, normalize(num)}} 77 def parse(%{before: cur}), do: {:ok, {:back, cur, def_page_size()}} 78 79 def parse(%{first: num}), do: {:ok, {:forw, nil, normalize(num)}} 80 def parse(%{last: num}), do: {:ok, {:back, nil, normalize(num)}} 81 82 def parse(_), do: {:ok, {:forw, nil, def_page_size()}} 83 84 @spec normalize(integer()) :: non_neg_integer() 85 defp normalize(num) do 86 # credo:disable-for-next-line Credo.Check.Refactor.MatchInCondition 87 if num > (max = max_page_size()), 88 do: max, 89 else: num 90 end 91 92 @doc """ 93 Page Ecto schemas. 94 95 Only supports forward or backward paging with or without cursors. 96 """ 97 @spec page(atom() | Ecto.Query.t(), params()) :: result() 98 def page(schema_or_query, params) do 99 with {:ok, {dir, cur, num}} <- parse(params) do 100 {page_fun, order_by} = 101 case dir do 102 :forw -> {&forw/3, [asc: :id]} 103 :back -> {&back/3, [desc: :id]} 104 end 105 where = 106 case {dir, cur} do 107 {_, nil} -> [] 108 {:forw, cur} -> dynamic([s], s.id > ^cur) 109 {:back, cur} -> dynamic([s], s.id < ^cur) 110 end 111 {:ok, 112 from(s in schema_or_query, 113 where: ^where, 114 order_by: ^order_by, 115 limit: ^num + 1, 116 select: %{cursor: s.id, node: s}) 117 |> Repo.all() 118 |> page_fun.(cur, num)} 119 end 120 end 121 122 @spec forw(edges(), ID.t() | nil, non_neg_integer()) :: t() 123 def forw(edges, cur, num) do 124 {edges, count} = 125 Enum.reduce(edges, {[], 0}, fn e, {edges, count} -> 126 {[e | edges], count + 1} 127 end) 128 129 {edges, has_next?, count} = 130 # we indeed have fetched num+1 records 131 if count - 1 == num do 132 [_ | edges] = edges 133 {edges, true, count - 1} 134 else 135 {edges, false, count} 136 end 137 138 {edges, first, last} = 139 case edges do 140 [] -> {[], nil, nil} 141 _ -> 142 [last | _] = edges 143 [first | _] = edges = Enum.reverse(edges) 144 {edges, first, last} 145 end 146 147 %{ 148 edges: edges, 149 page_info: %{ 150 start_cursor: first[:cursor], 151 end_cursor: last[:cursor], 152 has_next_page: has_next?, 153 has_previous_page: cur != nil, 154 total_count: count, 155 page_limit: num, 156 }, 157 } 158 end 159 160 @spec back(edges(), ID.t() | nil, non_neg_integer()) :: t() 161 def back(edges, cur, num) do 162 # Currently, this part of the algorithm doesn't care about 163 # whether we do forward or backward paging. 164 forw(edges, cur, num) 165 end 166 end