sequel-seek-pagination 0.1.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 +7 -0
- data/.gitignore +22 -0
- data/.rspec +2 -0
- data/Gemfile +10 -0
- data/LICENSE.txt +22 -0
- data/README.md +56 -0
- data/Rakefile +3 -0
- data/lib/sequel-seek-pagination.rb +1 -0
- data/lib/sequel/extensions/seek_pagination.rb +183 -0
- data/lib/sequel/extensions/seek_pagination/version.rb +5 -0
- data/sequel-seek-pagination.gemspec +26 -0
- data/spec/seek_pagination_spec.rb +212 -0
- data/spec/spec_helper.rb +38 -0
- data/tasks/benchmark.rb +59 -0
- data/tasks/specs.rb +10 -0
- metadata +116 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 3c6ce6e2a2e78ab2b3741bc2037a2ef40bb85cc7
|
4
|
+
data.tar.gz: 4d1dd2e3c9852312ba44ad35b389d45de5aca54a
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: c189067b2cc80490d41c684a891ec3486eaa0a675f599bfe3bcb066fceb5a698ce79fdc2aceb0e7f683f37e6a30e0af20f763ba82b3a883004fc53960dbca3a2
|
7
|
+
data.tar.gz: 040bfa003540c47c7faa0367685c0d718bfe19955ce26fed037835a22ec3319e89da21e60123134830f540aef1f8693cc21dcec92f849af39c7a033f5fb70de4
|
data/.gitignore
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
*.gem
|
2
|
+
*.rbc
|
3
|
+
.bundle
|
4
|
+
.config
|
5
|
+
.yardoc
|
6
|
+
Gemfile.lock
|
7
|
+
InstalledFiles
|
8
|
+
_yardoc
|
9
|
+
coverage
|
10
|
+
doc/
|
11
|
+
lib/bundler/man
|
12
|
+
pkg
|
13
|
+
rdoc
|
14
|
+
spec/reports
|
15
|
+
test/tmp
|
16
|
+
test/version_tmp
|
17
|
+
tmp
|
18
|
+
*.bundle
|
19
|
+
*.so
|
20
|
+
*.o
|
21
|
+
*.a
|
22
|
+
mkmf.log
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2015 Chris Hanks
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,56 @@
|
|
1
|
+
# Sequel::SeekPagination
|
2
|
+
|
3
|
+
This gem provides support for seek pagination (aka keyset pagination) to
|
4
|
+
Sequel and PostgreSQL. In seek pagination, you pass the pagination function
|
5
|
+
the last data you saw, and it returns the next page in the set. Example:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
# Activate the extension:
|
9
|
+
Sequel::Database.extension :seek_pagination
|
10
|
+
# or
|
11
|
+
DB.extension :seek_pagination
|
12
|
+
# or
|
13
|
+
ds = DB[:seek]
|
14
|
+
ds.extension(:seek_pagination)
|
15
|
+
|
16
|
+
# Use the new Dataset#seek_paginate method to get the first page.
|
17
|
+
DB[:seek].order(:id).seek_paginate(50) # SELECT * FROM "seek" ORDER BY "id" LIMIT 50
|
18
|
+
|
19
|
+
# Use the last data you saw to get the second page.
|
20
|
+
# (suppose the id of the last row you got was 1456)
|
21
|
+
DB[:seek].order(:id).seek_paginate(50, after: 1456) # SELECT * FROM "seek" WHERE ("id" > 1456) ORDER BY "id" LIMIT 50
|
22
|
+
|
23
|
+
# Also works when sorting by multiple columns.
|
24
|
+
DB[:seek].order(:col1, :col2).seek_paginate(50) # SELECT * FROM "seek" ORDER BY "col1", "col2" LIMIT 50
|
25
|
+
DB[:seek].order(:col1, :col2).seek_paginate(50, after: [12, 56]) # SELECT * FROM "seek" WHERE (("col1", "col2") > (12, 56)) ORDER BY "col1", "col2" LIMIT 50
|
26
|
+
```
|
27
|
+
|
28
|
+
### Why Seek Pagination?
|
29
|
+
|
30
|
+
Performance. The WHERE conditions generated above can use an index much more
|
31
|
+
efficiently on deeper pages. For example, using traditional LIMIT/OFFSET
|
32
|
+
pagination, retrieving the 9th page of 30 records requires that the database
|
33
|
+
process at least 270 rows to get the ones you want. With seek pagination,
|
34
|
+
getting the 100th page is just as efficient as getting the second.
|
35
|
+
|
36
|
+
Additionally, there's no slow count(*) of the entire table to get the number
|
37
|
+
of pages that are available. This is especially valuable when you're querying
|
38
|
+
on a complex join or the like.
|
39
|
+
|
40
|
+
### Why Not Seek Pagination?
|
41
|
+
|
42
|
+
The total number of pages isn't available (unless you do a count(*) yourself),
|
43
|
+
and you can't jump to a specific page in the table. This makes seek pagination
|
44
|
+
ideal for infinitely scrolling pages.
|
45
|
+
|
46
|
+
### Caveats
|
47
|
+
|
48
|
+
It's advisable for the column set you're sorting on to be unique, or else
|
49
|
+
there's the risk that results will be duplicated if multiple rows have the
|
50
|
+
same value across a page break. You may be able to get away with doing this on
|
51
|
+
non-unique column sets if they have very high cardinality (for example, if one
|
52
|
+
of the columns is a timestamp that will rarely be repeated). If you need to
|
53
|
+
enforce a unique column set to get a stable sort, you can always add a unique
|
54
|
+
column to the end of the ordering (the primary key, for example).
|
55
|
+
|
56
|
+
The gem is tested on PostgreSQL. It may or may not work for other databases.
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'sequel/extensions/seek_pagination'
|
@@ -0,0 +1,183 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
require 'sequel/extensions/seek_pagination/version'
|
3
|
+
|
4
|
+
module Sequel
|
5
|
+
module SeekPagination
|
6
|
+
class Error < StandardError; end
|
7
|
+
|
8
|
+
def seek_paginate(count, from: nil, after: nil, from_pk: nil, after_pk: nil, not_null: nil)
|
9
|
+
order = opts[:order]
|
10
|
+
model = @model
|
11
|
+
|
12
|
+
if order.nil? || order.length.zero?
|
13
|
+
raise Error, "cannot seek_paginate on a dataset with no order"
|
14
|
+
elsif from && after
|
15
|
+
raise Error, "cannot pass both :from and :after params to seek_paginate"
|
16
|
+
elsif model.nil? && (from_pk || after_pk)
|
17
|
+
raise Error, "passed the :#{from_pk ? 'from' : 'after'}_pk option to seek_paginate on a dataset that doesn't have an associated model"
|
18
|
+
end
|
19
|
+
|
20
|
+
ds = limit(count)
|
21
|
+
|
22
|
+
if values = from || after
|
23
|
+
values = Array(values)
|
24
|
+
|
25
|
+
if values.length != order.length
|
26
|
+
raise Error, "passed the wrong number of values in the :#{from ? 'from' : 'after'} option to seek_paginate"
|
27
|
+
end
|
28
|
+
elsif pk = from_pk || after_pk
|
29
|
+
# Need to load the order expressions for that pk from the DB.
|
30
|
+
selections = order.map { |o| Sequel::SQL::OrderedExpression === o ? o.expression : o }
|
31
|
+
|
32
|
+
# #get won't like it if we pass it bare expressions, so give it aliases for everything.
|
33
|
+
gettable = selections.zip(:a..:z).map{|s,a| Sequel.as(s, a)}
|
34
|
+
|
35
|
+
values = where(model.qualified_primary_key_hash(pk)).get(gettable)
|
36
|
+
end
|
37
|
+
|
38
|
+
if values
|
39
|
+
if not_null.nil?
|
40
|
+
not_null = []
|
41
|
+
|
42
|
+
# If the dataset was chained off a model, use its stored schema
|
43
|
+
# information to figure out what columns are not null.
|
44
|
+
if model
|
45
|
+
model.db_schema.each do |column, schema|
|
46
|
+
not_null << column if schema[:allow_null] == false
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# If we're paginating with a :from value, we want to include the row
|
52
|
+
# that has those exact values.
|
53
|
+
OrderedColumnSet.new(order.zip(values), include_exact_match: !!(from || from_pk), not_null: not_null).apply(ds)
|
54
|
+
else
|
55
|
+
ds
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
class OrderedColumnSet
|
62
|
+
attr_reader :not_null, :include_exact_match, :orders
|
63
|
+
|
64
|
+
def initialize(order_values, include_exact_match:, not_null:)
|
65
|
+
@not_null = not_null
|
66
|
+
@include_exact_match = include_exact_match
|
67
|
+
@orders = order_values.map { |order, value| OrderedColumn.new(self, order, value) }
|
68
|
+
end
|
69
|
+
|
70
|
+
def apply(dataset)
|
71
|
+
length = orders.length
|
72
|
+
|
73
|
+
conditions =
|
74
|
+
# Handle the common case where we can do a simpler (and faster)
|
75
|
+
# WHERE (non_nullable_1, non_nullable_2) > (1, 2) clause.
|
76
|
+
if length > 1 && orders.all?(&:not_null) && has_uniform_order_direction?
|
77
|
+
method = orders.first.direction == :asc ? '>' : '<'
|
78
|
+
method << '='.freeze if include_exact_match
|
79
|
+
Sequel.virtual_row{|o| o.__send__(method, orders.map(&:name), orders.map(&:value))}
|
80
|
+
else
|
81
|
+
Sequel.&(
|
82
|
+
*length.times.map { |i|
|
83
|
+
allow_equal = include_exact_match || i != length - 1
|
84
|
+
conditions = orders[0..i]
|
85
|
+
|
86
|
+
if i.zero?
|
87
|
+
conditions[0].ineq(eq: allow_equal)
|
88
|
+
else
|
89
|
+
c = conditions[-2]
|
90
|
+
|
91
|
+
list = if filter = conditions[-1].ineq(eq: allow_equal)
|
92
|
+
[Sequel.&(c.eq_filter, filter)]
|
93
|
+
else
|
94
|
+
[c.eq_filter]
|
95
|
+
end
|
96
|
+
|
97
|
+
list += conditions[0..-2].map { |c| c.ineq(eq: false) }
|
98
|
+
|
99
|
+
Sequel.|(*list.compact)
|
100
|
+
end
|
101
|
+
}.compact
|
102
|
+
)
|
103
|
+
end
|
104
|
+
|
105
|
+
dataset.where(conditions)
|
106
|
+
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def has_uniform_order_direction?
|
111
|
+
direction = nil
|
112
|
+
orders.each do |order|
|
113
|
+
direction ||= order.direction
|
114
|
+
return false unless direction == order.direction
|
115
|
+
end
|
116
|
+
true
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
class OrderedColumn
|
121
|
+
attr_reader :name, :direction, :nulls, :value, :not_null
|
122
|
+
|
123
|
+
def initialize(set, order, value)
|
124
|
+
@set = set
|
125
|
+
@value = value
|
126
|
+
@name, @direction, @nulls =
|
127
|
+
case order
|
128
|
+
when Sequel::SQL::OrderedExpression
|
129
|
+
direction = order.descending ? :desc : :asc
|
130
|
+
nulls = order.nulls || default_nulls_option(direction)
|
131
|
+
[order.expression, direction, nulls]
|
132
|
+
else
|
133
|
+
[order, :asc, :last]
|
134
|
+
end
|
135
|
+
|
136
|
+
@not_null = set.not_null.include?(@name)
|
137
|
+
end
|
138
|
+
|
139
|
+
def nulls_option_is_default?
|
140
|
+
nulls == default_nulls_option(direction)
|
141
|
+
end
|
142
|
+
|
143
|
+
def eq_filter
|
144
|
+
{name => value}
|
145
|
+
end
|
146
|
+
|
147
|
+
def null_filter
|
148
|
+
{name => nil}
|
149
|
+
end
|
150
|
+
|
151
|
+
def ineq(eq: true)
|
152
|
+
nulls_upcoming = !not_null && nulls == :last
|
153
|
+
|
154
|
+
if !value.nil?
|
155
|
+
method = "#{direction == :asc ? '>' : '<'}#{'=' if eq}"
|
156
|
+
filter = Sequel.virtual_row{|o| o.__send__(method, name, value)}
|
157
|
+
nulls_upcoming ? Sequel.|(filter, null_filter) : filter
|
158
|
+
else
|
159
|
+
if nulls_upcoming && eq
|
160
|
+
null_filter
|
161
|
+
elsif !nulls_upcoming && !eq
|
162
|
+
Sequel.~(null_filter)
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
private
|
168
|
+
|
169
|
+
# By default, Postgres sorts NULLs as higher than any other value. So we
|
170
|
+
# can treat a plain column ASC as column ASC NULLS LAST, and a plain
|
171
|
+
# column DESC as column DESC NULLS FIRST.
|
172
|
+
def default_nulls_option(direction)
|
173
|
+
case direction
|
174
|
+
when :asc then :last
|
175
|
+
when :desc then :first
|
176
|
+
else raise "Bad direction: #{direction.inspect}"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
Dataset.register_extension(:seek_pagination, SeekPagination)
|
183
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'sequel/extensions/seek_pagination/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = 'sequel-seek-pagination'
|
8
|
+
spec.version = Sequel::SeekPagination::VERSION
|
9
|
+
spec.authors = ["Chris Hanks"]
|
10
|
+
spec.email = ['christopher.m.hanks@gmail.com']
|
11
|
+
spec.summary = %q{Seek pagination for Sequel + PostgreSQL}
|
12
|
+
spec.description = %q{Generic, flexible seek pagination implementation for Sequel and PostgreSQL}
|
13
|
+
spec.homepage = 'https://github.com/chanks/sequel-seek-pagination'
|
14
|
+
spec.license = 'MIT'
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ['lib']
|
20
|
+
|
21
|
+
spec.add_development_dependency 'bundler', '~> 1.6'
|
22
|
+
spec.add_development_dependency 'rake'
|
23
|
+
spec.add_development_dependency 'pg'
|
24
|
+
|
25
|
+
spec.add_dependency 'sequel', '~> 4.0'
|
26
|
+
end
|
@@ -0,0 +1,212 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
class SeekPaginationSpec < Minitest::Spec
|
4
|
+
SEEK_COUNT = DB[:seek].count
|
5
|
+
|
6
|
+
def assert_equal_results(ds1, ds2)
|
7
|
+
assert_equal ds1.all, ds2.all
|
8
|
+
end
|
9
|
+
|
10
|
+
def assert_error_message(message, &block)
|
11
|
+
error = assert_raises(Sequel::SeekPagination::Error, &block)
|
12
|
+
assert_equal message, error.message
|
13
|
+
end
|
14
|
+
|
15
|
+
class << self
|
16
|
+
def it_should_seek_paginate_properly(ordering)
|
17
|
+
columns = ordering.map do |order|
|
18
|
+
case order
|
19
|
+
when Sequel::SQL::OrderedExpression then order.expression
|
20
|
+
else order
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
[:plain, :model].each do |dataset_type|
|
25
|
+
describe "for a #{dataset_type} dataset" do
|
26
|
+
dataset = dataset_type == :plain ? DB[:seek] : SeekModel
|
27
|
+
dataset = dataset.order(*ordering)
|
28
|
+
|
29
|
+
# Can't pass any random expression to #get, so give them all aliases.
|
30
|
+
gettable = columns.zip(:a..:z).map{|c,a| c.as(a)}
|
31
|
+
|
32
|
+
it "should limit the dataset appropriately when a starting point is not given" do
|
33
|
+
assert_equal_results dataset.limit(10),
|
34
|
+
dataset.seek_paginate(10)
|
35
|
+
end
|
36
|
+
|
37
|
+
it "should page properly when given a point to start from/after" do
|
38
|
+
offset = rand(SEEK_COUNT)
|
39
|
+
values = dataset.offset(offset).get(gettable)
|
40
|
+
|
41
|
+
assert_equal_results dataset.offset(offset).limit(100),
|
42
|
+
dataset.seek_paginate(100, from: values)
|
43
|
+
|
44
|
+
assert_equal_results dataset.offset(offset + 1).limit(100),
|
45
|
+
dataset.seek_paginate(100, after: values)
|
46
|
+
|
47
|
+
if columns.length == 1
|
48
|
+
# Should wrap values in an array if necessary
|
49
|
+
assert_equal_results dataset.offset(offset).limit(100),
|
50
|
+
dataset.seek_paginate(100, from: values.first)
|
51
|
+
|
52
|
+
assert_equal_results dataset.offset(offset + 1).limit(100),
|
53
|
+
dataset.seek_paginate(100, after: values.first)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
it "should return correct results when nullability information is provided" do
|
58
|
+
offset = rand(SEEK_COUNT)
|
59
|
+
values = dataset.offset(offset).get(gettable)
|
60
|
+
|
61
|
+
assert_equal_results dataset.offset(offset).limit(100),
|
62
|
+
dataset.seek_paginate(100, from: values, not_null: [:id, :non_nullable_1, :non_nullable_2])
|
63
|
+
|
64
|
+
assert_equal_results dataset.offset(offset + 1).limit(100),
|
65
|
+
dataset.seek_paginate(100, after: values, not_null: [:id, :non_nullable_1, :non_nullable_2])
|
66
|
+
end
|
67
|
+
|
68
|
+
if dataset_type == :model
|
69
|
+
it "should page properly when given a primary key to start from/after" do
|
70
|
+
offset = rand(SEEK_COUNT)
|
71
|
+
id = dataset.offset(offset).get(:id)
|
72
|
+
|
73
|
+
assert_equal_results dataset.offset(offset).limit(100),
|
74
|
+
dataset.seek_paginate(100, from_pk: id)
|
75
|
+
|
76
|
+
assert_equal_results dataset.offset(offset + 1).limit(100),
|
77
|
+
dataset.seek_paginate(100, after_pk: id)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
describe "for ordering by a single not-null column in either order" do
|
86
|
+
[:id.asc, :id.desc].each do |o1|
|
87
|
+
it_should_seek_paginate_properly [o1]
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
describe "for ordering by two not-null columns in any order" do
|
92
|
+
[:not_nullable_1.asc, :not_nullable_1.desc].each do |o1|
|
93
|
+
[:id.asc, :id.desc].each do |o2|
|
94
|
+
it_should_seek_paginate_properly [o1, o2]
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
describe "for ordering by three not-null columns in any order" do
|
100
|
+
[:not_nullable_1.asc, :not_nullable_1.desc].each do |o1|
|
101
|
+
[:not_nullable_2.asc, :not_nullable_2.desc].each do |o2|
|
102
|
+
[:id.asc, :id.desc].each do |o3|
|
103
|
+
it_should_seek_paginate_properly [o1, o2, o3]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
describe "for ordering by a nullable column" do
|
110
|
+
# We still tack on :id because the ordering needs to be unique.
|
111
|
+
[:nullable_1.asc, :nullable_1.desc, :nullable_1.asc(nulls: :first), :nullable_1.desc(nulls: :last)].each do |o1|
|
112
|
+
[:id.asc, :id.desc].each do |o2|
|
113
|
+
it_should_seek_paginate_properly [o1, o2]
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
describe "for ordering by multiple nullable columns" do
|
119
|
+
# We still tack on :id because the ordering needs to be unique.
|
120
|
+
[:nullable_1.asc, :nullable_1.desc, :nullable_1.asc(nulls: :first), :nullable_1.desc(nulls: :last)].each do |o1|
|
121
|
+
[:nullable_2.asc, :nullable_2.desc, :nullable_2.asc(nulls: :first), :nullable_2.desc(nulls: :last)].each do |o2|
|
122
|
+
[:id.asc, :id.desc].each do |o3|
|
123
|
+
it_should_seek_paginate_properly [o1, o2, o3]
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
describe "for ordering by a mix of nullable and not-nullable columns" do
|
130
|
+
20.times do
|
131
|
+
columns = [
|
132
|
+
[:not_nullable_1, :not_nullable_1.asc, :not_nullable_1.desc],
|
133
|
+
[:not_nullable_2, :not_nullable_2.asc, :not_nullable_2.desc],
|
134
|
+
[:nullable_1, :nullable_1.asc, :nullable_1.desc, :nullable_1.asc(nulls: :first), :nullable_1.desc(nulls: :last)],
|
135
|
+
[:nullable_2, :nullable_2.asc, :nullable_2.desc, :nullable_2.asc(nulls: :first), :nullable_2.desc(nulls: :last)],
|
136
|
+
]
|
137
|
+
|
138
|
+
testing_columns = columns.sample(rand(columns.count) + 1).map(&:sample)
|
139
|
+
testing_columns << [:id, :id.asc, :id.desc].sample
|
140
|
+
|
141
|
+
it_should_seek_paginate_properly(testing_columns)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
describe "for ordering by a mix of expressions and columns" do
|
146
|
+
20.times do
|
147
|
+
columns = [
|
148
|
+
[:not_nullable_1, :not_nullable_1.asc, :not_nullable_1.desc, :not_nullable_1.sql_number % 10, (:not_nullable_1.sql_number % 10).asc, (:not_nullable_1.sql_number % 10).desc],
|
149
|
+
[:not_nullable_2, :not_nullable_2.asc, :not_nullable_2.desc, :not_nullable_2.sql_number % 10, (:not_nullable_2.sql_number % 10).asc, (:not_nullable_2.sql_number % 10).desc],
|
150
|
+
[:nullable_1, :nullable_1.asc, :nullable_1.desc, :nullable_1.asc(nulls: :first), :nullable_1.desc(nulls: :last), :nullable_1.sql_number % 10, (:nullable_1.sql_number % 10).asc, (:nullable_1.sql_number % 10).desc, (:nullable_1.sql_number % 10).asc(nulls: :first), (:nullable_1.sql_number % 10).desc(nulls: :last)],
|
151
|
+
[:nullable_2, :nullable_2.asc, :nullable_2.desc, :nullable_2.asc(nulls: :first), :nullable_2.desc(nulls: :last), :nullable_2.sql_number % 10, (:nullable_2.sql_number % 10).asc, (:nullable_2.sql_number % 10).desc, (:nullable_2.sql_number % 10).asc(nulls: :first), (:nullable_2.sql_number % 10).desc(nulls: :last)],
|
152
|
+
]
|
153
|
+
|
154
|
+
testing_columns = columns.sample(rand(columns.count) + 1).map(&:sample)
|
155
|
+
testing_columns << [:id, :id.asc, :id.desc].sample
|
156
|
+
|
157
|
+
it_should_seek_paginate_properly(testing_columns)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
it "should work for order clauses of many types" do
|
162
|
+
datasets = [
|
163
|
+
DB[:seek].order(:id),
|
164
|
+
DB[:seek].order(:seek__id),
|
165
|
+
DB[:seek].order(:id.asc),
|
166
|
+
DB[:seek].order(:seek__id.asc),
|
167
|
+
DB[:seek].order(:id.desc).reverse_order,
|
168
|
+
DB[:seek].order(:seek__id.desc).reverse_order,
|
169
|
+
]
|
170
|
+
|
171
|
+
# With point to start from/after:
|
172
|
+
id = DB[:seek].order(:id).offset(56).get(:id)
|
173
|
+
|
174
|
+
datasets.each do |dataset|
|
175
|
+
assert_equal_results DB[:seek].order(:id).limit(5),
|
176
|
+
dataset.seek_paginate(5)
|
177
|
+
|
178
|
+
assert_equal_results DB[:seek].order(:id).offset(56).limit(5),
|
179
|
+
dataset.seek_paginate(5, from: id)
|
180
|
+
|
181
|
+
assert_equal_results DB[:seek].order(:id).offset(57).limit(5),
|
182
|
+
dataset.seek_paginate(5, after: id)
|
183
|
+
end
|
184
|
+
end
|
185
|
+
|
186
|
+
it "should raise an error if the dataset is not ordered" do
|
187
|
+
assert_error_message("cannot seek_paginate on a dataset with no order") { DB[:seek].seek_paginate(30) }
|
188
|
+
end
|
189
|
+
|
190
|
+
it "should raise an error if the dataset is not ordered" do
|
191
|
+
assert_error_message("cannot pass both :from and :after params to seek_paginate") { DB[:seek].order(:id).seek_paginate(30, from: 3, after: 4) }
|
192
|
+
end
|
193
|
+
|
194
|
+
it "should raise an error if given the wrong number of values to from or after" do
|
195
|
+
assert_error_message("passed the wrong number of values in the :from option to seek_paginate") { DB[:seek].order(:id, :nullable_1).seek_paginate(30, from: [3]) }
|
196
|
+
assert_error_message("passed the wrong number of values in the :after option to seek_paginate") { DB[:seek].order(:id, :nullable_1).seek_paginate(30, after: [3]) }
|
197
|
+
assert_error_message("passed the wrong number of values in the :from option to seek_paginate") { DB[:seek].order(:id, :nullable_1).seek_paginate(30, from: [3, 4, 5]) }
|
198
|
+
assert_error_message("passed the wrong number of values in the :after option to seek_paginate") { DB[:seek].order(:id, :nullable_1).seek_paginate(30, after: [3, 4, 5]) }
|
199
|
+
end
|
200
|
+
|
201
|
+
it "should raise an error if from_pk or after_pk are passed to a dataset without an associated model" do
|
202
|
+
assert_error_message("passed the :from_pk option to seek_paginate on a dataset that doesn't have an associated model") { DB[:seek].order(:id, :nullable_1).seek_paginate(30, from_pk: 3) }
|
203
|
+
assert_error_message("passed the :after_pk option to seek_paginate on a dataset that doesn't have an associated model") { DB[:seek].order(:id, :nullable_1).seek_paginate(30, after_pk: 3) }
|
204
|
+
end
|
205
|
+
|
206
|
+
describe "when chained from a model" do
|
207
|
+
it "should be able to determine from the schema what columns are not null" do
|
208
|
+
assert_equal %(SELECT * FROM "seek" WHERE (("not_nullable_1", "not_nullable_2", "id") > (1, 2, 3)) ORDER BY "not_nullable_1", "not_nullable_2", "id" LIMIT 5),
|
209
|
+
SeekModel.order(:not_nullable_1, :not_nullable_2, :id).seek_paginate(5, after: [1, 2, 3]).sql
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
|
3
|
+
$: << File.join(File.dirname(__FILE__), '..', 'lib')
|
4
|
+
|
5
|
+
Sequel.extension :core_extensions
|
6
|
+
|
7
|
+
Sequel::Database.extension :seek_pagination
|
8
|
+
|
9
|
+
DB = Sequel.connect "postgres:///sequel-seek-pagination-test"
|
10
|
+
|
11
|
+
DB.drop_table? :seek
|
12
|
+
|
13
|
+
DB.create_table :seek do
|
14
|
+
primary_key :id
|
15
|
+
|
16
|
+
integer :not_nullable_1, null: false
|
17
|
+
integer :not_nullable_2, null: false
|
18
|
+
|
19
|
+
integer :nullable_1
|
20
|
+
integer :nullable_2
|
21
|
+
end
|
22
|
+
|
23
|
+
class SeekModel < Sequel::Model(:seek)
|
24
|
+
end
|
25
|
+
|
26
|
+
DB.run <<-SQL
|
27
|
+
INSERT INTO seek
|
28
|
+
(not_nullable_1, not_nullable_2, nullable_1, nullable_2)
|
29
|
+
SELECT trunc(random() * 10 + 1),
|
30
|
+
trunc(random() * 10 + 1),
|
31
|
+
CASE WHEN random() > 0.5 THEN trunc(random() * 10 + 1) ELSE NULL END,
|
32
|
+
CASE WHEN random() > 0.5 THEN trunc(random() * 10 + 1) ELSE NULL END
|
33
|
+
FROM generate_series(1, 100) s
|
34
|
+
SQL
|
35
|
+
|
36
|
+
require 'pry'
|
37
|
+
require 'minitest/autorun'
|
38
|
+
require 'minitest/rg'
|
data/tasks/benchmark.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
task :benchmark do
|
2
|
+
require 'sequel-seek-pagination'
|
3
|
+
|
4
|
+
RECORD_COUNT = 100000
|
5
|
+
ITERATION_COUNT = 10
|
6
|
+
|
7
|
+
Sequel.extension :core_extensions
|
8
|
+
Sequel::Database.extension :seek_pagination
|
9
|
+
|
10
|
+
DB = Sequel.connect "postgres:///sequel-seek-pagination-test"
|
11
|
+
|
12
|
+
DB.drop_table? :seek
|
13
|
+
|
14
|
+
DB.create_table :seek do
|
15
|
+
primary_key :id
|
16
|
+
|
17
|
+
integer :non_nullable_1, null: false
|
18
|
+
integer :non_nullable_2, null: false
|
19
|
+
|
20
|
+
integer :nullable_1
|
21
|
+
integer :nullable_2
|
22
|
+
|
23
|
+
text :content # Prevent index-only scans.
|
24
|
+
end
|
25
|
+
|
26
|
+
DB.run <<-SQL
|
27
|
+
INSERT INTO seek
|
28
|
+
(non_nullable_1, non_nullable_2, nullable_1, nullable_2, content)
|
29
|
+
SELECT trunc(random() * 10 + 1),
|
30
|
+
trunc(random() * 10 + 1),
|
31
|
+
CASE WHEN random() > 0.5 THEN trunc(random() * 10 + 1) ELSE NULL END,
|
32
|
+
CASE WHEN random() > 0.5 THEN trunc(random() * 10 + 1) ELSE NULL END,
|
33
|
+
md5(random()::text)
|
34
|
+
FROM generate_series(1, #{RECORD_COUNT}) s
|
35
|
+
SQL
|
36
|
+
|
37
|
+
DB.add_index :seek, [:non_nullable_1]
|
38
|
+
|
39
|
+
{
|
40
|
+
"1 column, not-null, ascending, no not-null information" => DB[:seek].order(:id.asc ).seek_paginate(30, after: rand(RECORD_COUNT) + 1),
|
41
|
+
"1 column, not-null, descending, no not-null information" => DB[:seek].order(:id.desc).seek_paginate(30, after: rand(RECORD_COUNT) + 1),
|
42
|
+
|
43
|
+
"1 column, not-null, ascending, with not-null information" => DB[:seek].order(:id.asc ).seek_paginate(30, after: rand(RECORD_COUNT) + 1, not_null: [:id, :not_nullable_1, :not_nullable_2]),
|
44
|
+
"1 column, not-null, descending, with not-null information" => DB[:seek].order(:id.desc).seek_paginate(30, after: rand(RECORD_COUNT) + 1, not_null: [:id, :not_nullable_1, :not_nullable_2]),
|
45
|
+
|
46
|
+
"2 columns, not-null, ascending, no not-null information" => DB[:seek].order(:non_nullable_1.asc, :id.asc ).seek_paginate(30, after: [5, rand(RECORD_COUNT) + 1]),
|
47
|
+
"2 columns, not-null, descending, no not-null information" => DB[:seek].order(:non_nullable_1.desc, :id.desc).seek_paginate(30, after: [5, rand(RECORD_COUNT) + 1]),
|
48
|
+
|
49
|
+
"2 columns, not-null, ascending, with not-null information" => DB[:seek].order(:non_nullable_1.asc, :id.asc ).seek_paginate(30, after: [5, rand(RECORD_COUNT) + 1], not_null: [:id, :non_nullable_1, :non_nullable_2]),
|
50
|
+
"2 columns, not-null, descending, with not-null information" => DB[:seek].order(:non_nullable_1.desc, :id.desc).seek_paginate(30, after: [5, rand(RECORD_COUNT) + 1], not_null: [:id, :non_nullable_1, :non_nullable_2]),
|
51
|
+
}.each do |description, ds|
|
52
|
+
puts
|
53
|
+
puts description + ':'
|
54
|
+
ds.explain(analyze: true) # Make sure everything is cached.
|
55
|
+
puts ds.sql
|
56
|
+
puts ds.explain(analyze: true)
|
57
|
+
puts
|
58
|
+
end
|
59
|
+
end
|
data/tasks/specs.rb
ADDED
metadata
ADDED
@@ -0,0 +1,116 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: sequel-seek-pagination
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Chris Hanks
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-12-03 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.6'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.6'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: pg
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: sequel
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '4.0'
|
62
|
+
type: :runtime
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '4.0'
|
69
|
+
description: Generic, flexible seek pagination implementation for Sequel and PostgreSQL
|
70
|
+
email:
|
71
|
+
- christopher.m.hanks@gmail.com
|
72
|
+
executables: []
|
73
|
+
extensions: []
|
74
|
+
extra_rdoc_files: []
|
75
|
+
files:
|
76
|
+
- ".gitignore"
|
77
|
+
- ".rspec"
|
78
|
+
- Gemfile
|
79
|
+
- LICENSE.txt
|
80
|
+
- README.md
|
81
|
+
- Rakefile
|
82
|
+
- lib/sequel-seek-pagination.rb
|
83
|
+
- lib/sequel/extensions/seek_pagination.rb
|
84
|
+
- lib/sequel/extensions/seek_pagination/version.rb
|
85
|
+
- sequel-seek-pagination.gemspec
|
86
|
+
- spec/seek_pagination_spec.rb
|
87
|
+
- spec/spec_helper.rb
|
88
|
+
- tasks/benchmark.rb
|
89
|
+
- tasks/specs.rb
|
90
|
+
homepage: https://github.com/chanks/sequel-seek-pagination
|
91
|
+
licenses:
|
92
|
+
- MIT
|
93
|
+
metadata: {}
|
94
|
+
post_install_message:
|
95
|
+
rdoc_options: []
|
96
|
+
require_paths:
|
97
|
+
- lib
|
98
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
99
|
+
requirements:
|
100
|
+
- - ">="
|
101
|
+
- !ruby/object:Gem::Version
|
102
|
+
version: '0'
|
103
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
104
|
+
requirements:
|
105
|
+
- - ">="
|
106
|
+
- !ruby/object:Gem::Version
|
107
|
+
version: '0'
|
108
|
+
requirements: []
|
109
|
+
rubyforge_project:
|
110
|
+
rubygems_version: 2.4.8
|
111
|
+
signing_key:
|
112
|
+
specification_version: 4
|
113
|
+
summary: Seek pagination for Sequel + PostgreSQL
|
114
|
+
test_files:
|
115
|
+
- spec/seek_pagination_spec.rb
|
116
|
+
- spec/spec_helper.rb
|