page_cursor 1.0.0 → 2.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|