page_cursor 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +43 -3
- data/lib/page_cursor/cursor.rb +247 -51
- data/lib/page_cursor/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: bc17e3a2f1646fe6cd177515fc81cd3bd67877e26562938780249532a6c3f75c
|
4
|
+
data.tar.gz: bd134f3c437cdc8ed21e4f2fde253bdbd319e4941b2ddb4bafe839f1da35e498
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 311d31e985abf64c5a7ba7471554bc33d80bbeec415938f2d18e018c4e91f26698214887f80b9d113f3ccf4ab357d2b63e6c7b7fa9b8c2baa7ef132011d4a1bb
|
7
|
+
data.tar.gz: 6d354c73561bd0f8d18d76016d8b68b37181ae33ea94830ff37792f58f8108ba991bf30a2961d3d912fb511f9047dc6a0bd5e3468c66b19a63bc9e6d30ca9e6b
|
data/README.md
CHANGED
@@ -1,15 +1,25 @@
|
|
1
1
|
# page_cursor
|
2
2
|
|
3
|
-
Cursor-based pagination for Rails.
|
4
|
-
|
5
3
|
```ruby
|
6
4
|
gem 'page_cursor'
|
7
5
|
```
|
8
6
|
|
7
|
+
Cursor-based pagination for Rails.
|
8
|
+
|
9
|
+
* Does not use `OFFSET/LIMIT` queries.
|
10
|
+
* Cursors are primary keys, i.e. `{before: "<pk>", after: "<pk>"}` and are expected to be present as
|
11
|
+
`params[:after]` and/or `params[:before]`.
|
12
|
+
Primary keys must be unique and __sortable__.
|
13
|
+
* Multiple columns can be used for ordering (see examples below).
|
14
|
+
|
15
|
+
Works great in combination with [KSUID](https://github.com/mattes/ksuid-ruby)s as primary keys, but
|
16
|
+
any other sortable key will do.
|
17
|
+
|
18
|
+
|
9
19
|
## Usage
|
10
20
|
|
11
21
|
```ruby
|
12
|
-
@cursor, @
|
22
|
+
@cursor, @users = paginate(User.where(active: true)) # in controller
|
13
23
|
|
14
24
|
<%= pagination_nav @cursor %> # in view
|
15
25
|
```
|
@@ -18,3 +28,33 @@ Please note that you'll have to create the `pagination_nav` helper yourself. Hav
|
|
18
28
|
at an [example helper](test/dummy/app/helpers/application_helper.rb) with its rendered
|
19
29
|
[example partial](test/dummy/app/views/layouts/_pagination_nav.html.erb).
|
20
30
|
|
31
|
+
## More examples
|
32
|
+
|
33
|
+
```ruby
|
34
|
+
# Example 1
|
35
|
+
@users = User.where(active: true)
|
36
|
+
@cursor, @users = paginate(@users, limit: 25)
|
37
|
+
|
38
|
+
# Example 2
|
39
|
+
@cursor, @users = paginate(User)
|
40
|
+
|
41
|
+
# Example 3
|
42
|
+
@users = User.where(active: true)
|
43
|
+
@cursor, @users = paginate(@users, :desc) # order primary key descending (defaults to :asc)
|
44
|
+
|
45
|
+
# Example 4
|
46
|
+
@users = User.where(active: true).order(:city => :desc)
|
47
|
+
@cursor, @users = paginate(@users)
|
48
|
+
|
49
|
+
# Example 5
|
50
|
+
@users = User.where(active: true).order(:lastname => :asc, :firstname => :asc, :city => :desc)
|
51
|
+
@cursor, @users = paginate(@users, :desc) # :desc orders primary key descending
|
52
|
+
|
53
|
+
# Example 6 - change position of primary key in sort order
|
54
|
+
@users = User.where(active: true).order(:city => :asc, :id => :desc, :city => :asc)
|
55
|
+
@cursor, @users = paginate(@users)
|
56
|
+
|
57
|
+
# Example 7
|
58
|
+
@cursor, @users = paginate(User, primary_key: "custom")
|
59
|
+
```
|
60
|
+
|
data/lib/page_cursor/cursor.rb
CHANGED
@@ -4,63 +4,259 @@ module PageCursor
|
|
4
4
|
# It uses params[:after] and params[:before] request variables.
|
5
5
|
# It assumes @record's primary key is sortable.
|
6
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
|
-
|
7
|
+
# opts[:primary_key] = string
|
8
|
+
# opts[:limit] = n
|
9
|
+
def paginate(c, direction = nil, **opts)
|
10
|
+
opts.symbolize_keys!
|
11
|
+
limit = opts[:limit]&.to_i || 10
|
12
|
+
|
13
|
+
raise ArgumentError, "direction must be either nil, :asc or :desc" unless [nil, :asc, :desc].include?(direction)
|
14
|
+
raise ArgumentError, "limit must be >= 1" unless limit >= 1
|
15
|
+
raise ArgumentError, "only provide one, either params[:after] or params[:before]" if params[:after].present? && params[:before].present?
|
16
|
+
|
17
|
+
# make sure we have a primary key
|
18
|
+
pk_name = (opts[:primary_key] || c.primary_key).to_s
|
19
|
+
if !c.column_names.include?(pk_name)
|
20
|
+
if opts[:primary_key].present?
|
21
|
+
raise ArgumentError, "column '#{opts[:primary_key]}' does not exist in table '#{c.table_name}'"
|
22
|
+
else
|
23
|
+
raise "table '#{c.table_name}' has no primary key"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# reference the table's primary key
|
28
|
+
pk = c.arel_table[pk_name]
|
29
|
+
raise ArgumentError, "expect primary key to be Arel::Attributes:Attribute instead of #{pk.class}" unless pk.is_a?(Arel::Attributes::Attribute)
|
30
|
+
|
31
|
+
# set cursor to :after/:before and the according pk_value from the params
|
32
|
+
cursor = nil
|
33
|
+
pk_value = nil
|
34
|
+
if params[:after].present?
|
35
|
+
cursor = :after
|
36
|
+
pk_value = params[:after]
|
37
|
+
elsif params[:before].present?
|
38
|
+
cursor = :before
|
39
|
+
pk_value = params[:before]
|
40
|
+
end
|
41
|
+
|
42
|
+
# always fetch limit + 1 to see if there are more records
|
43
|
+
c = c.limit(limit + 1)
|
44
|
+
|
45
|
+
all = []
|
46
|
+
|
47
|
+
# check if c already has one or more order directives set
|
48
|
+
unless already_has_order?(c)
|
49
|
+
# easy, no existing order directives, we'll just order by our primary key
|
50
|
+
comparison, order, reverse = ordering(direction || :asc, cursor)
|
51
|
+
c = c.where(pk.send(comparison, pk_value)) if comparison
|
52
|
+
c = c.reorder(pk.send(order)).all
|
53
|
+
c = c.reverse if reverse
|
54
|
+
all = c.to_a
|
55
|
+
else
|
56
|
+
# collection has order directives, we need to do a bit more work ...
|
57
|
+
|
58
|
+
# replace existing order with new one
|
59
|
+
c = reorder(c, cursor, pk, direction || :asc)
|
60
|
+
|
61
|
+
# if a cursor is given, we need to fetch its row from the database
|
62
|
+
# so that we can use the row's values for our where conditions.
|
63
|
+
unless cursor.nil?
|
64
|
+
row = find!(c, pk_name, pk_value)
|
65
|
+
c = where(c, cursor, row)
|
66
|
+
end
|
67
|
+
|
68
|
+
all = c.all.to_a
|
69
|
+
all = all.reverse if cursor == :before
|
70
|
+
end
|
71
|
+
|
72
|
+
has_more = all.size <= limit ? false : true
|
73
|
+
|
74
|
+
# return new after/before cursor and all results if there are no more results to expect after this
|
75
|
+
unless has_more
|
76
|
+
if cursor.nil?
|
77
|
+
return { :after => nil, :before => nil }, all # first and only page, no afters/befores
|
78
|
+
elsif cursor == :after
|
79
|
+
return { :after => nil, :before => all.first&.read_attribute(pk_name) }, all # last page, no afters
|
80
|
+
elsif cursor == :before
|
81
|
+
return { :after => all.last&.read_attribute(pk_name), :before => nil }, all # last page, no befores
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# return new after/before cursors and all results if there are more results to expect
|
86
|
+
if cursor == :before
|
87
|
+
all = all.last(all.size - 1)
|
88
|
+
else
|
89
|
+
all = all.first(all.size - 1)
|
90
|
+
end
|
91
|
+
|
92
|
+
if cursor.nil?
|
93
|
+
return { :after => all.last&.read_attribute(pk_name), :before => nil }, all # first page, continue after
|
94
|
+
elsif cursor == :after
|
95
|
+
return { :after => all.last&.read_attribute(pk_name), :before => all.first&.read_attribute(pk_name) }, all
|
96
|
+
elsif cursor == :before
|
97
|
+
return { :after => all.last&.read_attribute(pk_name), :before => all.first&.read_attribute(pk_name) }, all
|
98
|
+
end
|
99
|
+
|
100
|
+
fail "never" # safeguard if cursor has a weird value
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
# order = :asc|:desc
|
106
|
+
# cursor = nil|:after|:before
|
107
|
+
# returns comparison, order, reverse
|
108
|
+
def ordering(order, cursor)
|
109
|
+
raise ArgumentError, "'#{order}' must be either :asc or :desc" unless [:asc, :desc].include?(order)
|
110
|
+
raise ArgumentError, "'#{cursor}' must be either nil, :after or :before" unless [nil, :after, :before].include?(cursor)
|
111
|
+
|
112
|
+
if order == :asc
|
113
|
+
if cursor.nil?
|
114
|
+
return nil, :asc, false # asc - nil
|
115
|
+
elsif cursor == :after
|
116
|
+
return :gt, :asc, false # asc - after
|
117
|
+
else
|
118
|
+
return :lt, :desc, true # asc - before
|
119
|
+
end
|
120
|
+
else
|
121
|
+
if cursor.nil?
|
122
|
+
return nil, :desc, false # desc - nil
|
123
|
+
elsif cursor == :after
|
124
|
+
return :lt, :desc, false # desc - after
|
125
|
+
else
|
126
|
+
return :gt, :asc, true # desc - before
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
# reorder applies a new ordering to the collection considering
|
132
|
+
# the cursor's :after or :before value
|
133
|
+
def reorder(collection, cursor, pk, pk_direction)
|
134
|
+
x = []
|
135
|
+
collection.order_values.each do |v|
|
136
|
+
if cursor == :after || cursor.nil?
|
137
|
+
x << v
|
138
|
+
elsif cursor == :before
|
139
|
+
x << v.reverse
|
140
|
+
end
|
141
|
+
end
|
142
|
+
|
143
|
+
# also add our primary key, if it's not yet included in the existing order directives
|
144
|
+
unless order_includes_pk?(collection, pk)
|
145
|
+
if cursor == :after || cursor.nil?
|
146
|
+
x << pk.send(pk_direction)
|
147
|
+
elsif cursor == :before
|
148
|
+
x << pk.send(pk_direction).reverse
|
36
149
|
end
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
150
|
+
end
|
151
|
+
|
152
|
+
collection.reorder(x)
|
153
|
+
end
|
154
|
+
|
155
|
+
# where returns a where clause which finds a row in an ordered collection
|
156
|
+
def where(collection, cursor, row)
|
157
|
+
parts = []
|
158
|
+
values = []
|
159
|
+
|
160
|
+
# recursively build where query elements
|
161
|
+
i = collection.order_values.count
|
162
|
+
while i > 0
|
163
|
+
i -= 1
|
164
|
+
subparts = []
|
165
|
+
|
166
|
+
last = collection.order_values[i]
|
167
|
+
chain = i > 0 ? collection.order_values.first(i) : []
|
168
|
+
|
169
|
+
# iterate through and build elements from chain
|
170
|
+
chain.each do |v|
|
171
|
+
table_name, col_name = extract_column(v)
|
172
|
+
quoted_col = quote(table_name, col_name)
|
173
|
+
subparts << "#{quoted_col} = ?"
|
174
|
+
values << row[col_name]
|
49
175
|
end
|
50
|
-
r = collection.all.reverse
|
51
176
|
|
52
|
-
|
53
|
-
|
54
|
-
|
177
|
+
# build last element
|
178
|
+
table_name, col_name = extract_column(last)
|
179
|
+
quoted_col = quote(table_name, col_name)
|
180
|
+
last = last.reverse if cursor == :before # reverse the reverse from reordering
|
181
|
+
comparison, _, _ = ordering(last.direction, cursor)
|
182
|
+
comparison_str = comparison_to_s(comparison)
|
183
|
+
subparts << "#{quoted_col} #{comparison_str} ?"
|
184
|
+
values << row[col_name]
|
185
|
+
|
186
|
+
# merge subparts into all parts
|
187
|
+
parts << "(" + subparts.join(" AND ") + ")"
|
188
|
+
end
|
189
|
+
|
190
|
+
# build final where clause
|
191
|
+
query = parts.join(" OR ")
|
192
|
+
collection.where(values.prepend(query))
|
193
|
+
end
|
194
|
+
|
195
|
+
def find!(collection, pk_name, pk_value)
|
196
|
+
collection.reorder(nil).rewhere(pk_name => pk_value).take!
|
197
|
+
end
|
55
198
|
|
56
|
-
|
199
|
+
def quote(table, column)
|
200
|
+
raise ArgumentError, "column can't be blank" if column.blank?
|
201
|
+
c = ActiveRecord::Base.connection
|
202
|
+
if table.present?
|
203
|
+
c.quote_table_name(table.to_s) + "." + c.quote_column_name(column.to_s)
|
57
204
|
else
|
58
|
-
|
59
|
-
r = collection.reorder(pk.send(direction)).all
|
60
|
-
return { :after => nil, :before => nil }, r if r.size <= limit
|
61
|
-
r = r.first(r.size - 1)
|
62
|
-
return { :after => r.last&.id, :before => nil }, r
|
205
|
+
c.quote_column_name(column.to_s)
|
63
206
|
end
|
64
207
|
end
|
208
|
+
|
209
|
+
# extract_column returns table_name and column_name for order directive
|
210
|
+
def extract_column(v)
|
211
|
+
if v.is_a? String
|
212
|
+
# TODO We can't reliably parse table_name and column_name from string syntax?
|
213
|
+
raise ArgumentError, "order(string) syntax is not supported"
|
214
|
+
end
|
215
|
+
|
216
|
+
if v.is_a?(Arel::Nodes::Ascending) || v.is_a?(Arel::Nodes::Descending)
|
217
|
+
val = v.value
|
218
|
+
|
219
|
+
if val.is_a?(Arel::Nodes::NamedFunction)
|
220
|
+
if val.expressions && val.expressions.size > 0
|
221
|
+
raise ArgumentError, "only one expression supported for #{val.class}" if val.expressions.size > 1 # TODO can we support more?
|
222
|
+
x = val.expressions[0]
|
223
|
+
if x.is_a?(Arel::Attributes::Attribute)
|
224
|
+
return x.relation.table_name.to_s, x.name.to_s
|
225
|
+
end
|
226
|
+
end
|
227
|
+
end
|
228
|
+
|
229
|
+
if val.is_a?(Arel::Attributes::Attribute)
|
230
|
+
return val.relation.table_name.to_s, val.name.to_s
|
231
|
+
end
|
232
|
+
end
|
233
|
+
|
234
|
+
raise ArgumentError, "unsupported type '#{v.class}' for order directive '#{v}'"
|
235
|
+
end
|
236
|
+
|
237
|
+
# order_includes_pk returns true if collection's order directives contain pk
|
238
|
+
def order_includes_pk?(collection, pk)
|
239
|
+
raise ArgumentError, "nil primary key" if pk == nil
|
240
|
+
raise ArgumentError, "missing table or column name for primary key" if pk.name.blank? || pk.relation.table_name.blank?
|
241
|
+
|
242
|
+
collection.order_values.each do |v|
|
243
|
+
table_name, col_name = extract_column(v)
|
244
|
+
raise ArgumentError, "unable to extract table and column name from #{v}" if table_name.blank? || col_name.blank?
|
245
|
+
return true if col_name.to_s == pk.name.to_s && table_name.to_s == pk.relation.table_name.to_s
|
246
|
+
end
|
247
|
+
return false
|
248
|
+
end
|
249
|
+
|
250
|
+
# already_has_order? returns true if collection has order directives set already
|
251
|
+
def already_has_order?(collection)
|
252
|
+
return false if collection.order_values.blank?
|
253
|
+
collection.order_values.size > 0
|
254
|
+
end
|
255
|
+
|
256
|
+
def comparison_to_s(comparison)
|
257
|
+
return "<" if comparison.to_sym == :lt # less than
|
258
|
+
return ">" if comparison.to_sym == :gt # greater than
|
259
|
+
raise ArgumentError, "'#{comparison}' must be either :lt or :gt"
|
260
|
+
end
|
65
261
|
end
|
66
262
|
end
|
data/lib/page_cursor/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: page_cursor
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Matthias Kadenbach
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-10-
|
11
|
+
date: 2020-10-30 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|