ballot 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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/Contributing.md +68 -0
  3. data/History.md +5 -0
  4. data/Licence.md +27 -0
  5. data/Manifest.txt +68 -0
  6. data/README.rdoc +264 -0
  7. data/Rakefile +71 -0
  8. data/bin/ballot_generator +9 -0
  9. data/lib/ballot.rb +25 -0
  10. data/lib/ballot/action_controller.rb +32 -0
  11. data/lib/ballot/active_record.rb +152 -0
  12. data/lib/ballot/active_record/votable.rb +145 -0
  13. data/lib/ballot/active_record/vote.rb +35 -0
  14. data/lib/ballot/active_record/voter.rb +99 -0
  15. data/lib/ballot/railtie.rb +19 -0
  16. data/lib/ballot/sequel.rb +170 -0
  17. data/lib/ballot/sequel/vote.rb +99 -0
  18. data/lib/ballot/votable.rb +445 -0
  19. data/lib/ballot/vote.rb +129 -0
  20. data/lib/ballot/voter.rb +320 -0
  21. data/lib/ballot/words.rb +32 -0
  22. data/lib/generators/ballot.rb +40 -0
  23. data/lib/generators/ballot/install/install_generator.rb +27 -0
  24. data/lib/generators/ballot/install/templates/active_record/migration.rb +19 -0
  25. data/lib/generators/ballot/install/templates/sequel/migration.rb +25 -0
  26. data/lib/generators/ballot/standalone.rb +89 -0
  27. data/lib/generators/ballot/standalone/support.rb +70 -0
  28. data/lib/generators/ballot/summary/summary_generator.rb +27 -0
  29. data/lib/generators/ballot/summary/templates/active_record/migration.rb +15 -0
  30. data/lib/generators/ballot/summary/templates/sequel/migration.rb +20 -0
  31. data/lib/sequel/plugins/ballot_votable.rb +180 -0
  32. data/lib/sequel/plugins/ballot_voter.rb +125 -0
  33. data/test/active_record/ballot_votable_test.rb +16 -0
  34. data/test/active_record/ballot_voter_test.rb +13 -0
  35. data/test/active_record/rails_generator_test.rb +28 -0
  36. data/test/active_record/votable_voter_test.rb +19 -0
  37. data/test/generators/rails-activerecord/Rakefile +2 -0
  38. data/test/generators/rails-activerecord/app/.keep +0 -0
  39. data/test/generators/rails-activerecord/bin/rails +5 -0
  40. data/test/generators/rails-activerecord/config/application.rb +17 -0
  41. data/test/generators/rails-activerecord/config/boot.rb +3 -0
  42. data/test/generators/rails-activerecord/config/database.yml +12 -0
  43. data/test/generators/rails-activerecord/config/environment.rb +3 -0
  44. data/test/generators/rails-activerecord/config/routes.rb +3 -0
  45. data/test/generators/rails-activerecord/config/secrets.yml +5 -0
  46. data/test/generators/rails-activerecord/db/seeds.rb +1 -0
  47. data/test/generators/rails-activerecord/log/.keep +0 -0
  48. data/test/generators/rails-sequel/Rakefile +2 -0
  49. data/test/generators/rails-sequel/app/.keep +0 -0
  50. data/test/generators/rails-sequel/bin/rails +5 -0
  51. data/test/generators/rails-sequel/config/application.rb +14 -0
  52. data/test/generators/rails-sequel/config/boot.rb +3 -0
  53. data/test/generators/rails-sequel/config/database.yml +12 -0
  54. data/test/generators/rails-sequel/config/environment.rb +3 -0
  55. data/test/generators/rails-sequel/config/routes.rb +3 -0
  56. data/test/generators/rails-sequel/config/secrets.yml +5 -0
  57. data/test/generators/rails-sequel/db/seeds.rb +1 -0
  58. data/test/generators/rails-sequel/log/.keep +0 -0
  59. data/test/minitest_config.rb +14 -0
  60. data/test/sequel/ballot_votable_test.rb +45 -0
  61. data/test/sequel/ballot_voter_test.rb +42 -0
  62. data/test/sequel/rails_generator_test.rb +25 -0
  63. data/test/sequel/votable_voter_test.rb +19 -0
  64. data/test/sequel/vote_test.rb +105 -0
  65. data/test/support/active_record_setup.rb +145 -0
  66. data/test/support/generators_setup.rb +129 -0
  67. data/test/support/sequel_setup.rb +164 -0
  68. data/test/support/shared_examples/votable_examples.rb +630 -0
  69. data/test/support/shared_examples/voter_examples.rb +600 -0
  70. metadata +333 -0
@@ -0,0 +1,9 @@
1
+ #! /usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ git_path = File.expand_path('../../.git', __FILE__)
5
+ $LOAD_PATH.unshift(File.expand_path('../../lib', __FILE__)) if File.exist?(git_path)
6
+
7
+ require 'generators/ballot/standalone'
8
+
9
+ exit Ballot::Generators::Standalone.run(ARGV).to_i
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Ballot provides a two-way polymorphic scoped voting mechanism for both
5
+ # ActiveRecord (4 or later) and Sequel (4 or later).
6
+ #
7
+ # - Two-way polymorphic: any model can be a voter or a votable.
8
+ # - Scoped: multiple votes can be recorded for a votable, under different
9
+ # scopes.
10
+ #
11
+ # The API for Ballot is consistent across both supported ORMs.
12
+ module Ballot
13
+ VERSION = '1.0' #:nodoc:
14
+ end
15
+
16
+ #:stopdoc:
17
+ require 'ballot/words'
18
+ require 'ballot/sequel' if defined?(::Sequel::Model)
19
+ if defined?(::Rails)
20
+ require 'ballot/railtie'
21
+ elsif defined?(::ActiveRecord::Base)
22
+ require 'ballot/active_record'
23
+ Ballot::ActiveRecord.inject!
24
+ end
25
+ #:startdoc:
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ # Extensions to \ActionController to support the use of Ballot in a Rails
6
+ # application.
7
+ module ActionController
8
+ # Provide a consistent way to extract ballot parameters from request
9
+ # parameters. The +ballot+ defaults to <tt>params[:ballot]</tt>.
10
+ #
11
+ # It permits:
12
+ #
13
+ # * Votables to be described by type and id (+:votable_type+,
14
+ # +:votable_id+), a GlobalID locator (+:votable_gid+), or an object
15
+ # (+:votable+).
16
+ # * Voters to be described by type and id (+:voter_type+, +:voter_id+), a
17
+ # GlobalID locator (+:voter_gid+), or an object (+:voter+).
18
+ # * The recorded vote (+:vote+, a word or value that means true or false,
19
+ # see Ballot::Words).
20
+ # * The scope for the vote (+:scope+).
21
+ # * The weight for the vote (+:weight+).
22
+ def ballot_params(ballot = params[:ballot])
23
+ ballot.permit(
24
+ :votable_type, :votable_id, :votable_gid, :votable,
25
+ :voter_type, :voter_id, :voter_gid, :voter,
26
+ :vote,
27
+ :scope,
28
+ :weight
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ # Extensions to \ActiveRecord to support Ballot.
6
+ module ActiveRecord
7
+ # Class method extensions to \ActiveRecord to support Ballot.
8
+ module ClassMethods
9
+ # ActiveRecord classes are not votable by default.
10
+ def ballot_votable?
11
+ false
12
+ end
13
+
14
+ # ActiveRecord classes are not voters by default.
15
+ def ballot_voter?
16
+ false
17
+ end
18
+
19
+ # The primary macro for marking an ActiveRecord class as votable.
20
+ def acts_as_ballot(*types)
21
+ types.each do |type|
22
+ case type.to_s
23
+ when 'votable'
24
+ require 'ballot/active_record/votable'
25
+ next if self < Ballot::ActiveRecord::Votable
26
+ include Ballot::ActiveRecord::Votable
27
+ when 'voter'
28
+ require 'ballot/active_record/voter'
29
+ next if self < Ballot::ActiveRecord::Voter
30
+ include Ballot::ActiveRecord::Voter
31
+ end
32
+ end
33
+ end
34
+
35
+ # A shorthand version of <tt>acts_as_ballot :votable</tt>.
36
+ def acts_as_ballot_votable
37
+ acts_as_ballot :votable
38
+ end
39
+
40
+ # A shorthand version of <tt>acts_as_ballot :voter</tt>.
41
+ def acts_as_ballot_voter
42
+ acts_as_ballot :voter
43
+ end
44
+ end
45
+
46
+ # Delegate the question of #votable? to the class.
47
+ def ballot_votable?
48
+ self.class.ballot_votable?
49
+ end
50
+
51
+ # Delegate the question of #voter? to the class.
52
+ def ballot_voter?
53
+ self.class.ballot_voter?
54
+ end
55
+
56
+ class << self
57
+ # Put Ballot into ActiveRecord::Base.
58
+ def inject!
59
+ ::ActiveRecord::Base.instance_eval do
60
+ return if self < Ballot::ActiveRecord
61
+
62
+ include ::Ballot::ActiveRecord
63
+ extend ::Ballot::ActiveRecord::ClassMethods
64
+ end
65
+ require 'ballot/active_record/vote'
66
+ end
67
+
68
+ # Respond with the canonical name for this model. Will be the root class
69
+ # if an STI model.
70
+ def type_name(model)
71
+ return model if model.kind_of?(String)
72
+ model = model.class if model.kind_of?(::ActiveRecord::Base)
73
+ model.base_class.name
74
+ end
75
+
76
+ # Return a valid Votable object from the provided item or +nil+.
77
+ # Permitted values are a Votable, a hash with the key +:votable+, or a
78
+ # hash with the keys +:votable_type+ and +:votable_id+.
79
+ def votable_for(item)
80
+ votable =
81
+ if item.kind_of?(::Ballot::ActiveRecord::Votable)
82
+ item
83
+ elsif item.kind_of?(Hash)
84
+ if item[:votable]
85
+ item[:votable]
86
+ elsif item[:votable_type] && item[:votable_id]
87
+ __instance_of_model(item[:votable_type], item[:votable_id])
88
+ elsif item[:votable_gid]
89
+ fail 'GlobalID is not enabled.' unless defined?(::GlobalID)
90
+
91
+ # This should actually be GlobalID::Sequel::Locator when I
92
+ # get that ported.
93
+ GlobalID::Locator.locate(item[:votable_gid])
94
+ end
95
+ end
96
+
97
+ votable if votable && votable.kind_of?(::ActiveRecord::Base) &&
98
+ votable.ballot_votable?
99
+ end
100
+
101
+ # Return the id and canonical votable type name for the item, using
102
+ # #votable_for.
103
+ def votable_id_and_type_name_for(item)
104
+ __id_and_type_name(votable_for(item))
105
+ end
106
+
107
+ # Return a valid Voter object from the provided item or +nil+. Permitted
108
+ # values are a Voter, a hash with the key +:voter+, or a hash with the
109
+ # keys +:voter_type+ and +:voter_id+.
110
+ def voter_for(item)
111
+ voter =
112
+ if item.kind_of?(::Ballot::ActiveRecord::Voter)
113
+ item
114
+ elsif item.kind_of?(Hash)
115
+ if item[:voter]
116
+ item[:voter]
117
+ elsif item[:voter_type] && item[:voter_id]
118
+ __instance_of_model(item[:voter_type], item[:voter_id])
119
+ elsif item[:voter_gid]
120
+ fail 'GlobalID is not enabled.' unless defined?(::GlobalID)
121
+
122
+ # This should actually be GlobalID::Sequel::Locator when I
123
+ # get that ported.
124
+ GlobalID::Locator.locate(item[:voter_gid])
125
+ end
126
+ end
127
+
128
+ voter if voter && voter.kind_of?(::ActiveRecord::Base) &&
129
+ voter.ballot_voter?
130
+ end
131
+
132
+ # Return the id and canonical voter type name for the item, using
133
+ # #voter_for.
134
+ def voter_id_and_type_name_for(item)
135
+ __id_and_type_name(voter_for(item))
136
+ end
137
+
138
+ private
139
+
140
+ # Return the id and canonical type name for the item.
141
+ def __id_and_type_name(item)
142
+ [ item.id, type_name(item) ] if item
143
+ end
144
+
145
+ def __instance_of_model(model, id)
146
+ model.constantize.find(id)
147
+ rescue
148
+ nil
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ module ActiveRecord
6
+ # The Ballot::ActiveRecord::Votable module is the ActiveRecord-specific
7
+ # implementation to enable Ballot::Votable for ActiveRecord.
8
+ module Votable
9
+ def self.included(model) #:nodoc:
10
+ require 'ballot/active_record/vote'
11
+ require 'ballot/words'
12
+ require 'ballot/votable'
13
+
14
+ model.class_eval do
15
+ # NOTE: This should only be done when Postgres JSON support is not
16
+ # enabled.
17
+ serialize :cached_ballot_summary, JSON
18
+
19
+ has_many :ballots_for,
20
+ class_name: '::Ballot::ActiveRecord::Vote',
21
+ as: :votable,
22
+ dependent: :destroy
23
+
24
+ include Ballot::Votable
25
+ extend Ballot::Votable::ClassMethods
26
+ end
27
+ end
28
+
29
+ def ballot_by(voter = nil, kwargs = {}) #:nodoc:
30
+ kwargs = { vote: true, scope: nil }.
31
+ merge(__ballot_votable_kwargs(voter, kwargs))
32
+ self.ballot_registered = false
33
+
34
+ voter_id, voter_type = Ballot::ActiveRecord.voter_id_and_type_name_for(kwargs)
35
+ return false unless voter_id
36
+
37
+ votes_ = find_ballots_for(
38
+ scope: kwargs[:scope],
39
+ voter_id: voter_id,
40
+ voter_type: voter_type
41
+ )
42
+
43
+ vote = if votes_.none? || kwargs[:duplicate]
44
+ Ballot::ActiveRecord::Vote.new(
45
+ votable: self,
46
+ voter_id: voter_id,
47
+ voter_type: voter_type,
48
+ scope: kwargs[:scope]
49
+ )
50
+ else
51
+ votes_.last
52
+ end
53
+
54
+ flag = Ballot::Words.truthy?(kwargs[:vote])
55
+ weight = kwargs[:weight] && kwargs[:weight].to_i || 1
56
+
57
+ return false if vote.vote == flag && vote.weight == weight
58
+
59
+ vote.vote = flag
60
+ vote.weight = weight
61
+
62
+ transaction do
63
+ if vote.save
64
+ self.ballot_registered = true
65
+ update_cached_votes kwargs[:scope]
66
+ true
67
+ end
68
+ end
69
+ end
70
+
71
+ def remove_ballot_by(voter = nil, kwargs = {}) #:nodoc:
72
+ kwargs = __ballot_votable_kwargs(voter, kwargs)
73
+ voter_id, voter_type = Ballot::ActiveRecord.voter_id_and_type_name_for(kwargs)
74
+ return false unless voter_id
75
+
76
+ votes_ = find_ballots_for(
77
+ scope: kwargs[:scope],
78
+ voter_id: voter_id,
79
+ voter_type: voter_type
80
+ )
81
+
82
+ return true if votes_.none?
83
+
84
+ transaction do
85
+ votes_.each(&:destroy)
86
+ update_cached_votes kwargs[:scope]
87
+ self.ballot_registered = ballots_for.any?
88
+ true
89
+ end
90
+ end
91
+
92
+ def ballot_by?(voter = nil, kwargs = {}) #:nodoc:
93
+ kwargs = __ballot_votable_kwargs(voter, kwargs)
94
+ voter_id, voter_type = Ballot::ActiveRecord.voter_id_and_type_name_for(kwargs)
95
+ return false unless voter_id
96
+
97
+ cond = {
98
+ voter_id: voter_id,
99
+ voter_type: voter_type,
100
+ scope: kwargs[:scope]
101
+ }
102
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
103
+
104
+ find_ballots_for(cond).any?
105
+ end
106
+
107
+ def ballots_by_class(klass, kwargs = {}) #:nodoc:
108
+ cond = {
109
+ voter_type: Ballot::ActiveRecord.type_name(klass),
110
+ scope: kwargs[:scope]
111
+ }
112
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
113
+
114
+ find_ballots_for(cond)
115
+ end
116
+
117
+ private
118
+
119
+ def caching_ballot_summary?
120
+ attribute_names.include?('cached_ballot_summary')
121
+ end
122
+
123
+ def find_ballots_for(*cond, &block)
124
+ if cond.empty? && block.nil?
125
+ ballots_for
126
+ else
127
+ ballots_for.where(*cond, &block)
128
+ end
129
+ end
130
+
131
+ def update_cached_votes(scope = nil)
132
+ return false unless caching_ballot_summary?
133
+
134
+ lock!
135
+ summary = cached_ballot_summary.merge(calculate_summary(scope))
136
+ self.cached_ballot_summary = summary
137
+ save!
138
+ end
139
+
140
+ def __eager_ballot_voters(ds)
141
+ ds.includes(:voter).map(&:voter)
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ module ActiveRecord
6
+ # The ActiveRecord implementation of Ballot::Vote.
7
+ class Vote < ::ActiveRecord::Base
8
+ self.table_name = 'ballot_votes'
9
+
10
+ scope :up, -> { where(vote: true) }
11
+ scope :down, -> { where(vote: false) }
12
+
13
+ scope :for_type, ->(model_class) {
14
+ where(votable_type: Ballot::ActiveRecord.type_name(model_class))
15
+ }
16
+ scope :by_type, ->(model_class) {
17
+ where(voter_type: Ballot::ActiveRecord.type_name(model_class))
18
+ }
19
+
20
+ if defined?(::ProtectedAttributes)
21
+ attr_accessible :votable_id, :votable_type, :votable,
22
+ :voter_id, :voter_type, :voter,
23
+ :vote, :scope, :weight
24
+ end
25
+
26
+ belongs_to :votable, polymorphic: true
27
+ belongs_to :voter, polymorphic: true
28
+
29
+ validates :votable_type, presence: { allow_blank: false }
30
+ validates :votable_id, presence: { allow_blank: false }
31
+ validates :voter_type, presence: { allow_blank: false }
32
+ validates :voter_id, presence: { allow_blank: false }
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ module ActiveRecord
6
+ # The Ballot::ActiveRecord::Voter module is the ActiveRecord-specific
7
+ # implementation to enable Ballot::Voter for ActiveRecord.
8
+ # for full details.
9
+ module Voter
10
+ def self.included(model) # :nodoc:
11
+ require 'ballot/active_record/vote'
12
+ require 'ballot/words'
13
+ require 'ballot/voter'
14
+
15
+ model.class_eval do
16
+ has_many :ballots_by,
17
+ class_name: '::Ballot::ActiveRecord::Vote',
18
+ as: :voter,
19
+ dependent: :destroy
20
+
21
+ include Ballot::Voter
22
+ extend Ballot::Voter::ClassMethods
23
+ end
24
+ end
25
+
26
+ def cast_ballot_for(votable = nil, kwargs = {}) #:nodoc:
27
+ kwargs = __ballot_voter_kwargs(votable, kwargs)
28
+ votable = Ballot::ActiveRecord.votable_for(kwargs)
29
+ return false unless votable
30
+ votable.ballot_by(kwargs.merge(voter: self))
31
+ end
32
+ alias ballot_for cast_ballot_for
33
+
34
+ def remove_ballot_for(votable = nil, kwargs = {}) #:nodoc:
35
+ kwargs = __ballot_voter_kwargs(votable, kwargs)
36
+ votable = Ballot::ActiveRecord.votable_for(kwargs)
37
+ return false unless votable
38
+ votable.remove_ballot_by voter: self, scope: kwargs[:scope]
39
+ end
40
+
41
+ def cast_ballot_for?(votable = nil, kwargs = {}) #:nodoc:
42
+ kwargs = __ballot_voter_kwargs(votable, kwargs)
43
+ votable_id, votable_type =
44
+ Ballot::ActiveRecord.votable_id_and_type_name_for(kwargs)
45
+ return false unless votable_id
46
+
47
+ cond = {
48
+ votable_id: votable_id,
49
+ votable_type: votable_type,
50
+ scope: kwargs[:scope]
51
+ }
52
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
53
+
54
+ find_ballots_by(cond).any?
55
+ end
56
+ alias ballot_for? cast_ballot_for?
57
+
58
+ def ballot_as_cast_for(votable = nil, kwargs = {}) # :nodoc:
59
+ kwargs = __ballot_voter_kwargs(votable, kwargs)
60
+ votable = Ballot::ActiveRecord.votable_for(kwargs)
61
+ return nil unless votable
62
+
63
+ cond = {
64
+ votable_id: votable.id,
65
+ votable_type: Ballot::ActiveRecord.type_name(votable),
66
+ scope: kwargs[:scope]
67
+ }
68
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
69
+
70
+ vote = find_ballots_by(cond).last
71
+ vote && vote.vote
72
+ end
73
+
74
+ def ballots_for_class(klass, kwargs = {}) #:nodoc:
75
+ cond = {
76
+ votable_type: Ballot::ActiveRecord.type_name(klass),
77
+ scope: kwargs[:scope]
78
+ }
79
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
80
+
81
+ find_ballots_by(cond)
82
+ end
83
+
84
+ private
85
+
86
+ def find_ballots_by(*cond, &block)
87
+ if cond.empty? && block.nil?
88
+ ballots_by
89
+ else
90
+ ballots_by.where(*cond, &block)
91
+ end
92
+ end
93
+
94
+ def __eager_ballot_votables(ds)
95
+ ds.includes(:votable).map(&:votable)
96
+ end
97
+ end
98
+ end
99
+ end