sequel-seek-pagination 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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
@@ -0,0 +1,2 @@
1
+ --color
2
+ --order random
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
4
+
5
+ group :test do
6
+ gem 'minitest', '5.5.1'
7
+ gem 'minitest-rg', '5.1.0'
8
+
9
+ gem 'pry'
10
+ end
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,3 @@
1
+ require 'bundler/gem_tasks'
2
+
3
+ Dir["./tasks/*.rb"].sort.each &method(:require)
@@ -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,5 @@
1
+ module Sequel
2
+ module SeekPagination
3
+ VERSION = '0.1.0'
4
+ end
5
+ 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
@@ -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'
@@ -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
@@ -0,0 +1,10 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new :default do |t|
5
+ t.libs = ['spec']
6
+ t.pattern = 'spec/**/*_spec.rb'
7
+ end
8
+
9
+ task :spec => :default
10
+ task :test => :default
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