pageturner 1.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.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 128baac342d6a592fff5b5c7d5064c589a94fa20
4
+ data.tar.gz: 0533f51c7ae40bc2975e1c8cc57241a989e07353
5
+ SHA512:
6
+ metadata.gz: d2f9071c28a1371e973a49e8905c9adfca6deb60bece647380cfb17a9a1f6917766ab841a2f3945d6a3ad80f2ff92361dd8577a955270713943128ca9997013b
7
+ data.tar.gz: 8e0e0e40e10a493e840bdd400b5f21719e7cdcf077c773ed1fdb4cb975c2299cef6915a3957ba2094a4798f53e7531657004621ef40cabdcc8bcbe73d0751cbc
@@ -0,0 +1,13 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+
10
+ # rspec failure tracking
11
+ .rspec_status
12
+
13
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.4.2
5
+ before_install: gem install bundler -v 1.16.0
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ # Specify your gem's dependencies in pageturner.gemspec
6
+ gemspec
@@ -0,0 +1,78 @@
1
+ # Pageturner
2
+
3
+ Value based pagination for `ActiveRecord::Relation` instances.
4
+
5
+ TODO: remove page size. let the caller limit the relation accordingly
6
+ TODO: remove the path helper. expose the cursor instead and let the caller generate the next link
7
+ TODO: automate testing setup that requires installing mysql, starting the server, creating the database, etc.
8
+ TODO: make gem database agnostic
9
+
10
+ ## Usage
11
+
12
+ In your controllers:
13
+
14
+ ```ruby
15
+ @pagination = Pageturner.new(
16
+ anchor_column: "name",
17
+ anchor_id: params[:last_id],
18
+ anchor_value: params[:last_value],
19
+ ar_relation: Model.where(params[:filters]),
20
+ sort_direction: Pageturner::DESC,
21
+ path_helper: lambda do |anchor_column:, anchor_value:, sort_direction:, anchor_id:|
22
+ models_path(
23
+ anchor_column: anchor_column,
24
+ anchor_value: anchor_value,
25
+ sort_direction: sort_direction,
26
+ anchor_id: anchor_id
27
+ )
28
+ end
29
+ )
30
+ ```
31
+
32
+ In your views:
33
+
34
+ ```ruby
35
+ json.models @pagination.resources do |resource|
36
+ json.partial! 'attributes', model: resource
37
+ end
38
+
39
+ json.pagination do
40
+ json.next_page @pagination.next_page
41
+ end
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### `#next_page`
47
+
48
+ Returns the next page's path.
49
+
50
+ ### `#resources`
51
+
52
+ Returns the current page's collection.
53
+
54
+ ## Installation
55
+
56
+ Add this line to your application's Gemfile:
57
+
58
+ ```ruby
59
+ gem 'pageturner'
60
+ ```
61
+
62
+ And then execute:
63
+
64
+ $ bundle
65
+
66
+ Or install it yourself as:
67
+
68
+ $ gem install pageturner
69
+
70
+ ## Development
71
+
72
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
73
+
74
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
75
+
76
+ ## Contributing
77
+
78
+ Bug reports and pull requests are welcome on GitHub at https://github.com/crzrcn/pageturner.
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "pageturner"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start(__FILE__)
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,169 @@
1
+ class Pageturner
2
+ ASC = "asc"
3
+ COMPARATOR_INTERNAL_BUG = "sort_direction input has an unexpected value. Contact Pageturner mantainer for support."
4
+ DESC = "desc"
5
+ GREATER_THAN_OPERATOR = ">"
6
+ LESS_THAN_OPERATOR = "<"
7
+ PAGE_SIZE = 3 # TODO: remove since it's a caller concern.
8
+
9
+ # @param [String] anchor_column - Field to paginate on.
10
+ # @param [String|Number|nil] anchor_value - Value of the anchor_column for the record to paginate from.
11
+ # @param [ActiveRecord::Relation] ar_relation - Resource collection to paginate.
12
+ # @param [String] sort_direction - Order of the pagination. Valid values: Pageturner::ASC, Pageturner::DESC.
13
+ # @param [Proc] path_helper - Function that generates the next page link. This function should have any state that's not relevant to pagination partially applied.
14
+ # @param [Number] anchor_id - ID of the record to paginate from.
15
+ def initialize(anchor_column:, anchor_value:, ar_relation:, sort_direction:, path_helper:, anchor_id:)
16
+ # To be used outside dynamic SQL statements.
17
+ @anchor_column = anchor_column
18
+
19
+ # To be used in dynamic SQL queries.
20
+ @sql_anchor_column = ActiveRecord::Base.connection.quote_column_name(anchor_column)
21
+
22
+ @anchor_value = anchor_value
23
+
24
+ @ar_relation = ar_relation
25
+
26
+ # Always select id to use it as secondary sort to prevent non-deterministic ordering when primary sort is on a non-unique column.
27
+ unless primary_key_selected?(@ar_relation)
28
+ raise Exception::PrimaryKeyNotSelected, "The provided ActiveRecord::Relation does not have the primary key selected. Cursor pagination requires a primary key to paginate undeterministic columns."
29
+ end
30
+
31
+ @sort_direction = sort_direction
32
+ @path_helper = path_helper
33
+ @anchor_id = anchor_id
34
+
35
+ unless [Pageturner::ASC, Pageturner::DESC].include?(@sort_direction)
36
+ raise Exception::InvalidSortDirection, "The provided order is not supported. Supported order values are: '#{ASC}', '#{DESC}'"
37
+ end
38
+ end
39
+
40
+ def resources
41
+ # Memoize expensive querying.
42
+ return @result if defined?(@result)
43
+
44
+ @result = calculate_resources
45
+ end
46
+
47
+ def next_page
48
+ anchor_value =
49
+ if external_anchor_column?
50
+ public_send_chain_from_sql(self.resources.last, @anchor_column)
51
+ else
52
+ self.resources.last&.public_send(@anchor_column)
53
+ end
54
+
55
+ @path_helper.call(
56
+ anchor_column: @anchor_column,
57
+ anchor_value: anchor_value,
58
+ sort_direction: @sort_direction,
59
+ anchor_id: self.resources.last&.id
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def calculate_resources
66
+ resources =
67
+ if can_calculate_nth_page?
68
+ calculate_next_page
69
+ else
70
+ calculate_first_page
71
+ end
72
+
73
+ resources
74
+ end
75
+
76
+ def comparator_for_fetching_resources
77
+ if @sort_direction == Pageturner::ASC
78
+ GREATER_THAN_OPERATOR
79
+ elsif @sort_direction == Pageturner::DESC
80
+ LESS_THAN_OPERATOR
81
+ else
82
+ raise COMPARATOR_INTERNAL_BUG
83
+ end
84
+ end
85
+
86
+ def can_calculate_nth_page?
87
+ @anchor_column && @anchor_id
88
+ end
89
+
90
+ def calculate_first_page
91
+ apply_sort_direction(@ar_relation)
92
+ end
93
+
94
+ def calculate_next_page
95
+ if external_anchor_column?
96
+ table, column = @anchor_column.split(".")
97
+
98
+ qualified_anchor_column = "`#{table}`.`#{column}`"
99
+ else
100
+ qualified_anchor_column = "#{@ar_relation.quoted_table_name}.#{@sql_anchor_column}"
101
+ end
102
+
103
+ qualified_anchor_pk_column = "#{@ar_relation.quoted_table_name}.#{@ar_relation.quoted_primary_key}"
104
+
105
+ where_clause =
106
+ if nulls_listed_first? && !@anchor_value.nil?
107
+ @ar_relation.where("(#{qualified_anchor_column}, #{qualified_anchor_pk_column}) #{comparator_for_fetching_resources} (?, ?)", @anchor_value, @anchor_id)
108
+ elsif nulls_listed_first? && @anchor_value.nil?
109
+ @ar_relation.where("(#{qualified_anchor_column} IS NULL AND #{qualified_anchor_pk_column} #{comparator_for_fetching_resources} ?) OR (#{qualified_anchor_column} IS NOT NULL)", @anchor_id)
110
+ elsif nulls_listed_last? && !@anchor_value.nil?
111
+ @ar_relation.where("(#{qualified_anchor_column} IS NULL) OR ((#{qualified_anchor_column}, #{qualified_anchor_pk_column}) #{comparator_for_fetching_resources} (?, ?))", @anchor_value, @anchor_id)
112
+ elsif nulls_listed_last? && @anchor_value.nil?
113
+ @ar_relation.where("#{qualified_anchor_column} IS NULL AND #{qualified_anchor_pk_column} #{comparator_for_fetching_resources} ?", @anchor_id)
114
+ end
115
+
116
+ apply_sort_direction(where_clause)
117
+ end
118
+
119
+ def apply_sort_direction(ar_relation)
120
+ ar_relation
121
+ .order("#{@anchor_column} #{@sort_direction}", @ar_relation.primary_key => @sort_direction)
122
+ .limit(PAGE_SIZE)
123
+ end
124
+
125
+ def nulls_listed_first?
126
+ # MySQL sorts nulls first for ascending order, and null last for descending order.
127
+ # TODO:
128
+ # The concept of null ordering should be database agnostic and modifiable.
129
+ # For example, UX might decide that we need some other type of ordering.
130
+ @sort_direction == Pageturner::ASC
131
+ end
132
+
133
+ def nulls_listed_last?
134
+ !nulls_listed_first?
135
+ end
136
+
137
+ def primary_key_selected?(ar_relation)
138
+ ar_relation.select_values.empty? || ar_relation.select_values.include?(@ar_relation.primary_key.to_sym)
139
+ end
140
+
141
+ # Gets an ActiveRecord instance's associated attribute via its qualified column sql identifier.
142
+ #
143
+ # Given qualified_column_sql_identifier = "joined_tables.column", it will call `.joined_table.column` on `object`.
144
+ # `object` must be an ActiveRecord instance.
145
+ def public_send_chain_from_sql(object, qualified_column_sql_identifier)
146
+ # We limit `split` to 2 because we are assuming that the SQL identifier is in table.column format.
147
+ association, attribute = qualified_column_sql_identifier.split(".", 2).each_with_index.map do |string, index|
148
+ # The first element should be the joined table's sql identifier.
149
+ # These are always pluralized, so we must singularize it in order to use it as a method call.
150
+ index == 0 ? string.singularize : string
151
+ end
152
+
153
+ object.try!(association).try!(attribute)
154
+ end
155
+
156
+ # Heuristic to check if the `anchor_column` is a joined column.
157
+ def external_anchor_column?
158
+ @anchor_column.include?(".")
159
+ end
160
+
161
+ class Exception
162
+ class InvalidSortDirection < StandardError
163
+ end
164
+
165
+ class PrimaryKeyNotSelected < StandardError
166
+ end
167
+ end
168
+ end
169
+
@@ -0,0 +1,30 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "pageturner"
7
+ spec.version = "1.0.0"
8
+ spec.authors = ["crzrcn"]
9
+ spec.email = ["fernanlink@gmail.com"]
10
+
11
+ spec.summary = %q{ Write a short summary, because RubyGems requires one.}
12
+ spec.description = %q{ Write a longer description or delete this line.}
13
+ spec.homepage = "https://github.com/crzrcn/pageturner"
14
+
15
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
16
+ f.match(%r{^(test|spec|features)/})
17
+ end
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+ spec.license = "MPL-2.0"
22
+
23
+ spec.add_dependency "activerecord", [">= 4.2", "<= 5.1"]
24
+
25
+ spec.add_development_dependency "bundler", "~> 1.16"
26
+ spec.add_development_dependency "rake", "~> 10.5"
27
+ spec.add_development_dependency "rspec", "~> 3.7"
28
+ spec.add_development_dependency "mysql2", "~> 0.4"
29
+ spec.add_development_dependency "pry-byebug", "~> 3.5"
30
+ end
metadata ADDED
@@ -0,0 +1,144 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pageturner
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - crzrcn
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2017-12-15 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activerecord
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '4.2'
20
+ - - "<="
21
+ - !ruby/object:Gem::Version
22
+ version: '5.1'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '4.2'
30
+ - - "<="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.1'
33
+ - !ruby/object:Gem::Dependency
34
+ name: bundler
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.16'
40
+ type: :development
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '1.16'
47
+ - !ruby/object:Gem::Dependency
48
+ name: rake
49
+ requirement: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '10.5'
54
+ type: :development
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '10.5'
61
+ - !ruby/object:Gem::Dependency
62
+ name: rspec
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '3.7'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.7'
75
+ - !ruby/object:Gem::Dependency
76
+ name: mysql2
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.4'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.4'
89
+ - !ruby/object:Gem::Dependency
90
+ name: pry-byebug
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '3.5'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.5'
103
+ description: " Write a longer description or delete this line."
104
+ email:
105
+ - fernanlink@gmail.com
106
+ executables: []
107
+ extensions: []
108
+ extra_rdoc_files: []
109
+ files:
110
+ - ".gitignore"
111
+ - ".rspec"
112
+ - ".travis.yml"
113
+ - Gemfile
114
+ - README.md
115
+ - Rakefile
116
+ - bin/console
117
+ - bin/setup
118
+ - lib/pageturner.rb
119
+ - pageturner.gemspec
120
+ homepage: https://github.com/crzrcn/pageturner
121
+ licenses:
122
+ - MPL-2.0
123
+ metadata: {}
124
+ post_install_message:
125
+ rdoc_options: []
126
+ require_paths:
127
+ - lib
128
+ required_ruby_version: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ required_rubygems_version: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ requirements: []
139
+ rubyforge_project:
140
+ rubygems_version: 2.5.2
141
+ signing_key:
142
+ specification_version: 4
143
+ summary: Write a short summary, because RubyGems requires one.
144
+ test_files: []