cursed 0.1.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: 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: