pageturner 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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: []