forked from duffelhq/paginator
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathpaginator.ex
409 lines (332 loc) · 12.9 KB
/
paginator.ex
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
# credo:disable-for-this-file Credo.Check.Refactor.NegatedIsNil
defmodule Paginator do
@moduledoc """
Defines a paginator.
This module adds a `paginate/3` function to your `Ecto.Repo` so that you can
paginate through results using opaque cursors.
## Usage
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
use Paginator
end
## Options
`Paginator` can take any options accepted by `paginate/3`. This is useful when
you want to enforce some options globally across your project.
### Example
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
use Paginator,
limit: 10, # sets the default limit to 10
maximum_limit: 100, # sets the maximum limit to 100
include_total_count: true, # include total count by default
total_count_primary_key_field: :uuid # sets the total_count_primary_key_field to uuid for calculate total_count
end
Note that these values can be still be overridden when `paginate/3` is called.
### Use without macros
If you wish to avoid use of macros or you wish to use a different name for
the pagination function you can define your own function like so:
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
def my_paginate_function(queryable, opts \\ [], repo_opts \\ []) do
defaults = [limit: 10] # Default options of your choice here
opts = Keyword.merge(defaults, opts)
Paginator.paginate(queryable, opts, __MODULE__, repo_opts)
end
end
"""
import Ecto.Query
alias Paginator.{Config, Cursor, Ecto.Query, Page, Page.Metadata}
@doc """
Fetches all the results matching the query within the cursors.
## Options
* `:after` - Fetch the records after this cursor.
* `:before` - Fetch the records before this cursor.
* `:cursor_fields` - The fields with sorting direction used to determine the
cursor. In most cases, this should be the same fields as the ones used for sorting in the query.
When you use named bindings in your query they can also be provided.
* `:fetch_cursor_value_fun` function of arity 2 to lookup cursor values on returned records.
Defaults to `Paginator.default_fetch_cursor_value/2`
* `:include_total_count` - Set this to true to return the total number of
records matching the query. Note that this number will be capped by
`:total_count_limit`. Defaults to `false`.
* `:total_count_primary_key_field` - Running count queries on specified column of the table
* `:limit` - Limits the number of records returned per page. Note that this
number will be capped by `:maximum_limit`. Defaults to `50`.
* `:maximum_limit` - Sets a maximum cap for `:limit`. This option can be useful when `:limit`
is set dynamically (e.g from a URL param set by a user) but you still want to
enfore a maximum. Defaults to `500`.
* `:sort_direction` - The direction used for sorting. Defaults to `:asc`.
It is preferred to set the sorting direction per field in `:cursor_fields`.
* `: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`.
## Repo options
This will be passed directly to `Ecto.Repo.all/2`, as such any option supported
by this function can be used here.
## Simple example
query = from(p in Post, order_by: [asc: p.inserted_at, asc: p.id], select: p)
Repo.paginate(query, cursor_fields: [:inserted_at, :id], limit: 50)
## Example with using custom sort directions per field
query = from(p in Post, order_by: [asc: p.inserted_at, desc: p.id], select: p)
Repo.paginate(query, cursor_fields: [inserted_at: :asc, id: :desc], limit: 50)
## Example with sorting on columns in joined tables
from(
p in Post,
as: :posts,
join: a in assoc(p, :author),
as: :author,
preload: [author: a],
select: p,
order_by: [
{:asc, a.name},
{:asc, p.id}
]
)
Repo.paginate(query, cursor_fields: [{{:author, :name}, :asc}, id: :asc], limit: 50)
When sorting on columns in joined tables it is necessary to use named bindings. In
this case we name it `author`. In the `cursor_fields` we refer to this named binding
and its column name.
To build the cursor Paginator uses the returned Ecto.Schema. When using a joined
column the returned Ecto.Schema won't have the value of the joined column
unless we preload it. E.g. in this case the cursor will be build up from
`post.id` and `post.author.name`. This presupposes that the named of the
binding is the same as the name of the relationship on the original struct.
One level deep joins are supported out of the box but if we join on a second
level, e.g. `post.author.company.name` a custom function can be supplied to
handle the cursor value retrieval. This also applies when the named binding
does not map to the name of the relationship.
## Example
from(
p in Post,
as: :posts,
join: a in assoc(p, :author),
as: :author,
join: c in assoc(a, :company),
as: :company,
preload: [author: a],
select: p,
order_by: [
{:asc, a.name},
{:asc, p.id}
]
)
Repo.paginate(query,
cursor_fields: [{{:company, :name}, :asc}, id: :asc],
fetch_cursor_value_fun: fn
post, {:company, name} ->
post.author.company.name
post, field ->
Paginator.default_fetch_cursor_value(post, field)
end,
limit: 50
)
"""
@callback paginate(queryable :: Ecto.Query.t(), opts :: Keyword.t(), repo_opts :: Keyword.t()) ::
Paginator.Page.t()
defmacro __using__(opts) do
quote do
@defaults unquote(opts)
def paginate(queryable, opts \\ [], repo_opts \\ []) do
opts = Keyword.merge(@defaults, opts)
Paginator.paginate(queryable, opts, __MODULE__, repo_opts)
end
end
end
@doc false
def paginate(queryable, opts, repo, repo_opts) do
config = Config.new(opts)
Config.validate!(config)
sorted_entries = entries(queryable, config, repo, repo_opts)
paginated_entries = paginate_entries(sorted_entries, config)
{total_count, total_count_cap_exceeded} = total_count(queryable, config, repo, repo_opts)
%Page{
entries: paginated_entries,
metadata: %Metadata{
before: before_cursor(paginated_entries, sorted_entries, config),
after: after_cursor(paginated_entries, sorted_entries, config),
limit: config.limit,
total_count: total_count,
total_count_cap_exceeded: total_count_cap_exceeded
}
}
end
@doc """
Generate a cursor for the supplied record, in the same manner as the
`before` and `after` cursors generated by `paginate/3`.
For the cursor to be compatible with `paginate/3`, `cursor_fields`
must have the same value as the `cursor_fields` option passed to it.
### Example
iex> Paginator.cursor_for_record(%Paginator.Customer{id: 1}, [:id])
"g3QAAAABZAACaWRhAQ=="
iex> Paginator.cursor_for_record(%Paginator.Customer{id: 1, name: "Alice"}, [id: :asc, name: :desc])
"g3QAAAACZAACaWRhAWQABG5hbWVtAAAABUFsaWNl"
"""
@spec cursor_for_record(
any(),
[atom() | {atom(), atom()}],
(map(), atom() | {atom(), atom()} -> any())
) :: binary()
def cursor_for_record(
record,
cursor_fields,
fetch_cursor_value_fun \\ &Paginator.default_fetch_cursor_value/2
) do
fetch_cursor_value(record, %Config{
cursor_fields: cursor_fields,
fetch_cursor_value_fun: fetch_cursor_value_fun
})
end
@doc """
Default function used to get the value of a cursor field from the supplied
map. This function can be overridden in the `Paginator.Config` using the
`fetch_cursor_value_fun` key.
When using named bindings to sort on joined columns it will attempt to get
the value of joined column by using the named binding as the name of the
relationship on the original Ecto.Schema.
### Example
iex> Paginator.default_fetch_cursor_value(%Paginator.Customer{id: 1}, :id)
1
iex> Paginator.default_fetch_cursor_value(%Paginator.Customer{id: 1, address: %Paginator.Address{city: "London"}}, {:address, :city})
"London"
"""
@spec default_fetch_cursor_value(map(), atom() | {atom(), atom()}) :: any()
def default_fetch_cursor_value(schema, {binding, field})
when is_atom(binding) and is_atom(field) do
case Map.get(schema, field) do
nil -> schema |> Map.get(binding) |> Map.get(field)
value -> value
end
end
def default_fetch_cursor_value(schema, field) when is_atom(field) do
Map.get(schema, field)
end
defp before_cursor([], [], _config), do: nil
defp before_cursor(_paginated_entries, _sorted_entries, %Config{after: nil, before: nil}),
do: nil
defp before_cursor(paginated_entries, _sorted_entries, %Config{after: c_after} = config)
when not is_nil(c_after) 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
else
first_or_nil(paginated_entries, config)
end
end
defp first_or_nil(entries, config) do
if first = List.first(entries) do
fetch_cursor_value(first, config)
else
nil
end
end
defp after_cursor([], [], _config), do: nil
defp after_cursor(paginated_entries, _sorted_entries, %Config{before: c_before} = config)
when not is_nil(c_before) 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
else
last_or_nil(paginated_entries, config)
end
end
defp last_or_nil(entries, config) do
if last = List.last(entries) do
fetch_cursor_value(last, config)
else
nil
end
end
defp fetch_cursor_value(schema, %Config{
cursor_fields: cursor_fields,
fetch_cursor_value_fun: fetch_cursor_value_fun
}) do
cursor_fields
|> Enum.map(fn
{{cursor_field, func}, _order} when is_atom(cursor_field) and is_function(func) ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}
{cursor_field, func} when is_atom(cursor_field) and is_function(func) ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}
{cursor_field, _order} ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}
cursor_field when is_atom(cursor_field) ->
{cursor_field, fetch_cursor_value_fun.(schema, cursor_field)}
end)
|> Map.new()
|> Cursor.encode()
end
defp first_page?(sorted_entries, %Config{limit: limit}) do
Enum.count(sorted_entries) <= limit
end
defp last_page?(sorted_entries, %Config{limit: limit}) do
Enum.count(sorted_entries) <= limit
end
defp entries(queryable, config, repo, repo_opts) do
queryable
|> Query.paginate(config)
|> repo.all(repo_opts)
end
defp total_count(_queryable, %Config{include_total_count: false}, _repo, _repo_opts),
do: {nil, nil}
defp total_count(
queryable,
%Config{
total_count_limit: :infinity,
total_count_primary_key_field: total_count_primary_key_field
},
repo,
repo_opts
) do
result =
queryable
|> exclude(:preload)
|> exclude(:select)
|> exclude(:order_by)
|> select([e], map(e, [total_count_primary_key_field]))
|> subquery
|> select(count("*"))
|> repo.one(repo_opts)
{result, false}
end
defp total_count(
queryable,
%Config{
total_count_limit: total_count_limit,
total_count_primary_key_field: total_count_primary_key_field
},
repo,
repo_opts
) do
result =
queryable
|> exclude(:preload)
|> exclude(:select)
|> exclude(:order_by)
|> limit(^(total_count_limit + 1))
|> select([e], map(e, [total_count_primary_key_field]))
|> subquery
|> select(count("*"))
|> repo.one(repo_opts)
{
Enum.min([result, total_count_limit]),
result > total_count_limit
}
end
# `sorted_entries` returns (limit+1) records, so before
# returning the page, we want to take only the first (limit).
#
# When we have only a before cursor, we get our results from
# sorted_entries in reverse order due t
defp paginate_entries(sorted_entries, %Config{before: before, after: nil, limit: limit})
when not is_nil(before) do
sorted_entries
|> Enum.take(limit)
|> Enum.reverse()
end
defp paginate_entries(sorted_entries, %Config{limit: limit}) do
Enum.take(sorted_entries, limit)
end
end