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 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