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.
@@ -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
@@ -0,0 +1 @@
1
+ DATABASE_URL: postgres://root:@localhost/cursed_test
@@ -0,0 +1,11 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /spec/examples.txt
10
+ /tmp/
11
+ .env
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --require spec_helper
@@ -0,0 +1,15 @@
1
+
2
+ AllCops:
3
+ TargetRubyVersion: 2.3
4
+
5
+ Style/Documentation:
6
+ Enabled: false
7
+
8
+ Style/ClassAndModuleChildren:
9
+ Enabled: false
10
+
11
+ Metrics/LineLength:
12
+ Max: 120
13
+
14
+ Metrics/ParameterLists:
15
+ Max: 10
@@ -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'
@@ -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.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+ require 'bundler/gem_tasks'
3
+ task default: :spec
@@ -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
@@ -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,11 @@
1
+ machine:
2
+ environment:
3
+ DATABASE_URL: postgresql://ubuntu:@localhost/circle_ruby_test
4
+
5
+ database:
6
+ override:
7
+ - createdb -O ubuntu circle_ruby_test
8
+
9
+ test:
10
+ override:
11
+ - bundle exec rspec
@@ -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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cursed/version'
4
+ require 'cursed/cursor'
5
+ require 'cursed/collection'
6
+ require 'cursed/adapter'
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'adapter/base'
4
+ require_relative 'adapter/sequel'
5
+ require_relative 'adapter/active_record'
6
+ require_relative 'adapter/array'
@@ -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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+ module Cursed
3
+ VERSION = '0.1.0'
4
+ 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: