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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 932517a4e798874d1d618d79ec7d96957a27997e3bcf7b755a8922e13b1d6563
4
- data.tar.gz: a4e27daca15f4d1f4fc83a8dbd467de05e35319e0ddfefdfa5495cd54371783e
3
+ metadata.gz: bc17e3a2f1646fe6cd177515fc81cd3bd67877e26562938780249532a6c3f75c
4
+ data.tar.gz: bd134f3c437cdc8ed21e4f2fde253bdbd319e4941b2ddb4bafe839f1da35e498
5
5
  SHA512:
6
- metadata.gz: 56a5c40258fc216d7dbcaf61e44514f400f5dc2e52d9cfa81020e5f4b6a3e587f806c7ff29c62e1d7c6ad4d57ba57aebcf614b2e714ce9d2e61442dac1174e41
7
- data.tar.gz: d49481ae21e0667fef99733800187caaed9dbecbcb173de4e42bf762cefd138015aa299ef345e198070963ee6d79865c7043bb45656a8f1a45c1a90272f4b20b
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, @records = paginate(User.where(active: true)) # in controller
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
+
@@ -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
- # Example usage:
8
- # ```
9
- # @cursor, @records = paginate(@records) # in controller
10
- # <%= pagination_nav @cursor %> # in view
11
- # ```
12
- #
13
- # @cursor is a hash returning the next (`after`)
14
- # and previous (`before`) primary key, if more records are available.
15
- #
16
- # Caveat: All collection's order statements are overwritten by paginate.
17
- def paginate(collection, direction = :asc, limit = 10)
18
- fail "direction must be :asc or :desc" unless [:asc, :desc].include?(direction)
19
- fail "limit must be >= 1" unless limit >= 1
20
-
21
- after = params[:after]
22
- before = params[:before]
23
- fail "only provide one, either params[:after] or params[:before]" if after.present? && before.present?
24
-
25
- # reference the table's primary key attribute
26
- pk = collection.arel_table[collection.primary_key]
27
-
28
- # return limit + 1 to see if there are more records
29
- collection = collection.limit(limit + 1)
30
-
31
- if after.present?
32
- if direction == :asc
33
- collection = collection.where(pk.send("gt", after)).reorder(pk.send(:asc))
34
- elsif direction == :desc
35
- collection = collection.where(pk.send("lt", after)).reorder(pk.send(:desc))
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
- r = collection.all
38
-
39
- return { :after => nil, :before => r.first&.id }, r if r.size <= limit
40
- r = r.first(r.size - 1)
41
- return { :after => r.last&.id, :before => r.first&.id }, r
42
-
43
- # ---
44
- elsif before.present?
45
- if direction == :asc
46
- collection = collection.where(pk.send("lt", before)).reorder(pk.send(:desc))
47
- elsif direction == :desc
48
- collection = collection.where(pk.send("gt", before)).reorder(pk.send(:asc))
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
- return { :after => r.last&.id, :before => nil }, r if r.size <= limit
53
- r = r.last(r.size - 1)
54
- return { :after => r.last&.id, :before => r.first&.id }, r
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
- # if after and before are both missing
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
@@ -1,3 +1,3 @@
1
1
  module PageCursor
2
- VERSION = "1.0.0"
2
+ VERSION = "2.0.0"
3
3
  end
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: 1.0.0
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-27 00:00:00.000000000 Z
11
+ date: 2020-10-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails