cursed 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/.env.example +1 -0
- data/.gitignore +11 -0
- data/.rspec +2 -0
- data/.rubocop.yml +15 -0
- data/.ruby-version +1 -0
- data/Gemfile +25 -0
- data/README.md +74 -0
- data/Rakefile +3 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/circle.yml +11 -0
- data/cursed.gemspec +19 -0
- data/lib/cursed.rb +6 -0
- data/lib/cursed/adapter.rb +6 -0
- data/lib/cursed/adapter/active_record.rb +27 -0
- data/lib/cursed/adapter/array.rb +28 -0
- data/lib/cursed/adapter/base.rb +31 -0
- data/lib/cursed/adapter/sequel.rb +27 -0
- data/lib/cursed/collection.rb +83 -0
- data/lib/cursed/cursor.rb +52 -0
- data/lib/cursed/version.rb +4 -0
- metadata +66 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 2af86e7660623d2cb8d56b06e3c30c4fd32b4ec3
|
4
|
+
data.tar.gz: 4b661132d4d8b4551abcdea318bfd9ac5e0a11d1
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: a7d526ef920e90529009fa27fe1bc1808b4565ddd5f57ae4d0dcad2d7fd7f8856165a7c2b3e5b69d957d13863087c52fd03309412569746a669b703a2c2f6b64
|
7
|
+
data.tar.gz: 2b709005118b8a5158f1f017ae131ae27e9191249a9a3a5fca0afa6882191e4fb4041562b74734ff94328ef02d78ae7a380cdccbc5438d606935aefd9af8cd5c
|
data/.env.example
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
DATABASE_URL: postgres://root:@localhost/cursed_test
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.3.1
|
data/Gemfile
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
source 'https://rubygems.org'
|
4
|
+
|
5
|
+
# Specify your gem's dependencies in cursed.gemspec
|
6
|
+
gemspec
|
7
|
+
|
8
|
+
gem 'rubocop'
|
9
|
+
|
10
|
+
gem 'activerecord', require: 'active_record'
|
11
|
+
|
12
|
+
gem 'sequel'
|
13
|
+
|
14
|
+
gem 'pg'
|
15
|
+
|
16
|
+
gem 'rspec'
|
17
|
+
gem 'rspec-its'
|
18
|
+
|
19
|
+
gem 'yard'
|
20
|
+
|
21
|
+
gem 'pry-byebug'
|
22
|
+
|
23
|
+
gem 'dotenv'
|
24
|
+
|
25
|
+
gem 'geminabox'
|
data/README.md
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
# Cursed
|
2
|
+
|
3
|
+
Cursed is a gem that implements the cursoring pattern in Postgres with the
|
4
|
+
ActiveRecord and Sequel gems. The cursoring pattern is an alternative to
|
5
|
+
traditional pagination which is superior in that it is stable for collections
|
6
|
+
that are constantly changing.
|
7
|
+
|
8
|
+
Instead of providing a parameter `page` we instead provide any of three
|
9
|
+
parameters `before`, `after` and `limit` (you may customize these as you wish).
|
10
|
+
By choosing to 'paginate' by providing the maximum ID you know about you can
|
11
|
+
be assured that the same record will not appear twice if records behind it have
|
12
|
+
changed it's position.
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
Add this line to your application's Gemfile:
|
17
|
+
|
18
|
+
```ruby
|
19
|
+
gem 'cursed'
|
20
|
+
```
|
21
|
+
|
22
|
+
And then execute:
|
23
|
+
|
24
|
+
$ bundle
|
25
|
+
|
26
|
+
Or install it yourself as:
|
27
|
+
|
28
|
+
$ gem install cursed
|
29
|
+
|
30
|
+
## Usage
|
31
|
+
|
32
|
+
In your controller code call your collection in this manner.
|
33
|
+
|
34
|
+
```ruby
|
35
|
+
Cursed::Collection.new(
|
36
|
+
relation: MyModel.unscoped,
|
37
|
+
cursor: Cursed::Cursor.new(
|
38
|
+
after: params[:after],
|
39
|
+
before: params[:before],
|
40
|
+
limit: params[:limit],
|
41
|
+
maximum: 20
|
42
|
+
)
|
43
|
+
)
|
44
|
+
```
|
45
|
+
|
46
|
+
The `Collection` is enumerable so you can use it as you would us any array
|
47
|
+
|
48
|
+
```erb
|
49
|
+
<% @collection.each do |record| %>
|
50
|
+
<%= record.name %>
|
51
|
+
<% end %>
|
52
|
+
```
|
53
|
+
|
54
|
+
To generate your next link and previous link merge in the values of `#next_page_params`
|
55
|
+
and `#prev_page_params` into your URL generator
|
56
|
+
|
57
|
+
```erb
|
58
|
+
<%= link_to 'Previous Page', widgets_path(collection.prev_page_params) %>
|
59
|
+
<%= link_to 'Next Page', widgets_path(collection.next_page_params) %>
|
60
|
+
```
|
61
|
+
|
62
|
+
## Development
|
63
|
+
|
64
|
+
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
65
|
+
|
66
|
+
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).
|
67
|
+
|
68
|
+
## Contributing
|
69
|
+
|
70
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/influitive/cursed.
|
71
|
+
|
72
|
+
## License
|
73
|
+
|
74
|
+
This gem is licensed under the MIT license.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require 'bundler/setup'
|
5
|
+
require 'cursed'
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require 'irb'
|
15
|
+
IRB.start
|
data/bin/setup
ADDED
data/circle.yml
ADDED
data/cursed.gemspec
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
lib = File.expand_path('../lib', __FILE__)
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
5
|
+
require 'cursed/version'
|
6
|
+
|
7
|
+
Gem::Specification.new do |spec|
|
8
|
+
spec.name = 'cursed'
|
9
|
+
spec.version = Cursed::VERSION
|
10
|
+
spec.licenses = ['MIT']
|
11
|
+
spec.authors = ['Will Howard']
|
12
|
+
spec.email = ['will@influitive.com']
|
13
|
+
spec.homepage = 'https://www.github.com/influitive/cursed'
|
14
|
+
|
15
|
+
spec.summary = 'PostgreSQL based cursoring in ActiveRecord and Sequel'
|
16
|
+
|
17
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
|
18
|
+
spec.require_paths = ['lib']
|
19
|
+
end
|
data/lib/cursed.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cursed
|
4
|
+
module Adapter
|
5
|
+
class ActiveRecord < Base
|
6
|
+
def descend_by(attribute)
|
7
|
+
@relation = relation.reorder(attribute => :desc)
|
8
|
+
end
|
9
|
+
|
10
|
+
def ascend_by(attribute)
|
11
|
+
@relation = relation.reorder(attribute => :asc)
|
12
|
+
end
|
13
|
+
|
14
|
+
def limit(count)
|
15
|
+
@relation = relation.limit(count)
|
16
|
+
end
|
17
|
+
|
18
|
+
def after(attribute, value)
|
19
|
+
@relation = relation.where(relation.table[attribute].gt(value))
|
20
|
+
end
|
21
|
+
|
22
|
+
def before(attribute, value)
|
23
|
+
@relation = relation.where(relation.table[attribute].lt(value))
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cursed
|
4
|
+
module Adapter
|
5
|
+
class Array < Base
|
6
|
+
def descend_by(attribute)
|
7
|
+
relation.sort_by!(&attribute)
|
8
|
+
relation.reverse!
|
9
|
+
end
|
10
|
+
|
11
|
+
def ascend_by(attribute)
|
12
|
+
relation.sort_by!(&attribute)
|
13
|
+
end
|
14
|
+
|
15
|
+
def limit(count)
|
16
|
+
@relation = relation.take(count)
|
17
|
+
end
|
18
|
+
|
19
|
+
def after(attribute, value)
|
20
|
+
relation.select! { |x| x.public_send(attribute) > value }
|
21
|
+
end
|
22
|
+
|
23
|
+
def before(attribute, value)
|
24
|
+
relation.select! { |x| x.public_send(attribute) < value }
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cursed
|
4
|
+
module Adapter
|
5
|
+
class Base
|
6
|
+
attr_reader :relation
|
7
|
+
|
8
|
+
def initialize(array)
|
9
|
+
@relation = array
|
10
|
+
end
|
11
|
+
|
12
|
+
def apply_to(cursor)
|
13
|
+
attr = cursor.attribute
|
14
|
+
|
15
|
+
after(attr, cursor.after) if cursor.after?
|
16
|
+
|
17
|
+
before(attr, cursor.before) if cursor.before?
|
18
|
+
|
19
|
+
if cursor.forward?
|
20
|
+
ascend_by(attr)
|
21
|
+
else
|
22
|
+
descend_by(attr)
|
23
|
+
end
|
24
|
+
|
25
|
+
limit(cursor.clamped_limit)
|
26
|
+
|
27
|
+
relation
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cursed
|
4
|
+
module Adapter
|
5
|
+
class Sequel < Base
|
6
|
+
def descend_by(attribute)
|
7
|
+
@relation = relation.order(attribute).reverse
|
8
|
+
end
|
9
|
+
|
10
|
+
def ascend_by(attribute)
|
11
|
+
@relation = relation.order(attribute)
|
12
|
+
end
|
13
|
+
|
14
|
+
def limit(count)
|
15
|
+
@relation = relation.limit(count)
|
16
|
+
end
|
17
|
+
|
18
|
+
def after(attribute, value)
|
19
|
+
@relation = relation.where(::Sequel.expr(attribute) > value)
|
20
|
+
end
|
21
|
+
|
22
|
+
def before(attribute, value)
|
23
|
+
@relation = relation.where(::Sequel.expr(attribute) < value)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,83 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cursed
|
4
|
+
class Collection
|
5
|
+
include Enumerable
|
6
|
+
|
7
|
+
attr_reader :relation, :cursor, :adapter
|
8
|
+
|
9
|
+
# @param relation [ActiveRecord::Relation or Sequel::Dataset] the relation to cursor on
|
10
|
+
# @param cursor [Cursor] the value object containing parameters for the cursor
|
11
|
+
# @param adapter [Adapter] an object which plays the Adapter role
|
12
|
+
def initialize(relation:, cursor:, adapter: nil)
|
13
|
+
@relation = relation
|
14
|
+
@cursor = cursor
|
15
|
+
@adapter = adapter || determine_adapter(relation)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Iterates through each element in the current page
|
19
|
+
def each(*args, &block)
|
20
|
+
collection.each(*args, &block)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Invalidates the local cache of the current page - the next call to {#each}
|
24
|
+
# (or any Enumerable method that calls it) will fetch a fresh page.
|
25
|
+
def invalidate!
|
26
|
+
@collection = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns the maximum cursor index in the current page
|
30
|
+
def maximum_id
|
31
|
+
collection.map(&cursor.attribute).max
|
32
|
+
end
|
33
|
+
|
34
|
+
# Returns the minimum cursor index in the current page
|
35
|
+
def minimum_id
|
36
|
+
collection.map(&cursor.attribute).min
|
37
|
+
end
|
38
|
+
|
39
|
+
# Returns a hash of parameters which should be used for generating a next
|
40
|
+
# page link.
|
41
|
+
# @return [Hash] a hash containing any combination of :before, :after, :limit
|
42
|
+
def next_page_params
|
43
|
+
if cursor.forward?
|
44
|
+
after_maximum_params
|
45
|
+
else
|
46
|
+
before_minimum_params
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Returns a hash of parameters which should be used for generating a previous
|
51
|
+
# page link.
|
52
|
+
# @return [Hash] a hash containing any combination of :before, :after, :limit
|
53
|
+
def prev_page_params
|
54
|
+
if cursor.forward?
|
55
|
+
before_minimum_params
|
56
|
+
else
|
57
|
+
after_maximum_params
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def collection
|
64
|
+
@collection ||= adapter.new(relation).apply_to(cursor).to_a
|
65
|
+
end
|
66
|
+
|
67
|
+
def determine_adapter(relation)
|
68
|
+
case relation
|
69
|
+
when Sequel::Dataset then Adapter::Sequel
|
70
|
+
when ActiveRecord::Base, ActiveRecord::Relation then Adapter::ActiveRecord
|
71
|
+
else raise ArgumentError, "unable to determine adapter for #{relation.inspect}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def after_maximum_params
|
76
|
+
{ after: maximum_id, limit: cursor.clamped_limit }
|
77
|
+
end
|
78
|
+
|
79
|
+
def before_minimum_params
|
80
|
+
{ before: minimum_id, limit: cursor.clamped_limit }
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Cursed
|
4
|
+
# Cursor is a value object that has all the parameters required to determine
|
5
|
+
# how to fetch a page of records using the cursor pattern.
|
6
|
+
class Cursor
|
7
|
+
DIRECTIONS = %i(forward backward).freeze
|
8
|
+
|
9
|
+
attr_reader :after, :before, :limit, :maximum, :attribute, :direction
|
10
|
+
|
11
|
+
# @param [Integer] after The cursor index to retrieve records after
|
12
|
+
# @param [Integer] before The cursor index to retrive records before
|
13
|
+
# @param [Integer] limit The maximum number of records to retrieve
|
14
|
+
# @param [Integer] maximum The maximum value that :limit can have
|
15
|
+
# @param [Symbol] attribute The name of the attribute to cursor upon
|
16
|
+
# @param [:forward or :backward] direction The direction the cursor will advance in the collection
|
17
|
+
def initialize(after: nil, before: nil, limit: 10, maximum: 20, attribute: :cursor, direction: :forward)
|
18
|
+
@after = Integer(after) unless after.nil?
|
19
|
+
@before = Integer(before) unless before.nil?
|
20
|
+
@limit = Integer(limit)
|
21
|
+
@maximum = Integer(maximum)
|
22
|
+
@attribute = attribute
|
23
|
+
@direction = direction
|
24
|
+
raise ArgumentError, "#{direction} is not a valid direction" unless DIRECTIONS.include?(direction)
|
25
|
+
end
|
26
|
+
|
27
|
+
# returns the value of {#limit} clamped into the range of 1..{#maximum} (inclusive)
|
28
|
+
def clamped_limit
|
29
|
+
[1, limit, maximum].sort[1]
|
30
|
+
end
|
31
|
+
|
32
|
+
# returns true when the direction is forward
|
33
|
+
def forward?
|
34
|
+
direction == :forward
|
35
|
+
end
|
36
|
+
|
37
|
+
# returns true when the direction is backward
|
38
|
+
def backward?
|
39
|
+
direction == :backward
|
40
|
+
end
|
41
|
+
|
42
|
+
# returns true when the before parameter is set to a non-nil value
|
43
|
+
def before?
|
44
|
+
!before.nil?
|
45
|
+
end
|
46
|
+
|
47
|
+
# returns true when the after parameter is set to a non-nil value
|
48
|
+
def after?
|
49
|
+
!after.nil?
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
metadata
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: cursed
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Will Howard
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-06-30 00:00:00.000000000 Z
|
12
|
+
dependencies: []
|
13
|
+
description:
|
14
|
+
email:
|
15
|
+
- will@influitive.com
|
16
|
+
executables: []
|
17
|
+
extensions: []
|
18
|
+
extra_rdoc_files: []
|
19
|
+
files:
|
20
|
+
- ".env.example"
|
21
|
+
- ".gitignore"
|
22
|
+
- ".rspec"
|
23
|
+
- ".rubocop.yml"
|
24
|
+
- ".ruby-version"
|
25
|
+
- Gemfile
|
26
|
+
- README.md
|
27
|
+
- Rakefile
|
28
|
+
- bin/console
|
29
|
+
- bin/setup
|
30
|
+
- circle.yml
|
31
|
+
- cursed.gemspec
|
32
|
+
- lib/cursed.rb
|
33
|
+
- lib/cursed/adapter.rb
|
34
|
+
- lib/cursed/adapter/active_record.rb
|
35
|
+
- lib/cursed/adapter/array.rb
|
36
|
+
- lib/cursed/adapter/base.rb
|
37
|
+
- lib/cursed/adapter/sequel.rb
|
38
|
+
- lib/cursed/collection.rb
|
39
|
+
- lib/cursed/cursor.rb
|
40
|
+
- lib/cursed/version.rb
|
41
|
+
homepage: https://www.github.com/influitive/cursed
|
42
|
+
licenses:
|
43
|
+
- MIT
|
44
|
+
metadata: {}
|
45
|
+
post_install_message:
|
46
|
+
rdoc_options: []
|
47
|
+
require_paths:
|
48
|
+
- lib
|
49
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
50
|
+
requirements:
|
51
|
+
- - ">="
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: '0'
|
59
|
+
requirements: []
|
60
|
+
rubyforge_project:
|
61
|
+
rubygems_version: 2.6.6
|
62
|
+
signing_key:
|
63
|
+
specification_version: 4
|
64
|
+
summary: PostgreSQL based cursoring in ActiveRecord and Sequel
|
65
|
+
test_files: []
|
66
|
+
has_rdoc:
|