ballot 1.0

Sign up to get free protection for your applications and to get access to all the features.
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,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ # = Ballot Railtie
6
+ class Railtie < ::Rails::Railtie # :nodoc:
7
+ initializer 'ballot' do
8
+ ActiveSupport.on_load(:active_record) do
9
+ require 'ballot/active_record'
10
+ Ballot::ActiveRecord.inject!
11
+ end
12
+
13
+ ActiveSupport.on_load(:action_controller) do
14
+ require 'ballot/action_controller'
15
+ include Ballot::ActionController
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ # Extensions to Sequel::Model to support Ballot.
6
+ module Sequel
7
+ # Class method extensions to Sequel::Model to support Ballot.
8
+ module ClassMethods
9
+ # Sequel::Model classes are not votable by default.
10
+ def ballot_votable?
11
+ false
12
+ end
13
+
14
+ # Sequel::Model classes are not voters by default.
15
+ def ballot_voter?
16
+ false
17
+ end
18
+
19
+ # This macro makes migrating from ActiveRecord to Sequel (mostly)
20
+ # painless. The preferred way is to simply enable the Sequel plug-in
21
+ # directly:
22
+ #
23
+ # class Voter
24
+ # plugin :ballot_voter
25
+ # end
26
+ #
27
+ # class Votable
28
+ # plugin :ballot_votable
29
+ # end
30
+ def acts_as_ballot(*types)
31
+ types.each do |type|
32
+ case type.to_s
33
+ when 'votable'
34
+ warn 'Prefer using the Sequel::Model plugin :ballot_votable directly.'
35
+ plugin :ballot_votable
36
+ when 'voter'
37
+ warn 'Prefer using the Sequel::Model plugin :ballot_voter directly.'
38
+ plugin :ballot_voter
39
+ end
40
+ end
41
+ end
42
+
43
+ # A shorthand version of <tt>acts_as_ballot :votable</tt>.
44
+ def acts_as_ballot_votable
45
+ acts_as_ballot :votable
46
+ end
47
+
48
+ # A shorthand version of <tt>acts_as_ballot :voter</tt>.
49
+ def acts_as_ballot_voter
50
+ acts_as_ballot :voter
51
+ end
52
+ end
53
+
54
+ # Delegate the question of #ballot_votable? to the class.
55
+ def ballot_votable?
56
+ self.class.ballot_votable?
57
+ end
58
+
59
+ # Delegate the question of #ballot_voter? to the class.
60
+ def ballot_voter?
61
+ self.class.ballot_voter?
62
+ end
63
+
64
+ class << self
65
+ # Respond with the canonical name for this model. This differs if the
66
+ # model is STI-enabled.
67
+ def type_name(model)
68
+ return model if model.kind_of?(String)
69
+ model = model.model if model.kind_of?(::Sequel::Model)
70
+ if model.respond_to?(:sti_dataset)
71
+ model.sti_dataset.model.name
72
+ else
73
+ model.name
74
+ end
75
+ end
76
+
77
+ # Return a valid Votable object from the provided item or +nil+.
78
+ # Permitted values are a Votable, a hash with the key +:votable+, or a
79
+ # hash with the keys +:votable_type+ and +:votable_id+.
80
+ def votable_for(item)
81
+ votable =
82
+ if item.kind_of?(::Sequel::Plugins::BallotVotable::InstanceMethods)
83
+ item
84
+ elsif item.kind_of?(Hash)
85
+ if item[:votable]
86
+ item[:votable]
87
+ elsif item[:votable_type] && item[:votable_id]
88
+ __instance_of_model(item[:votable_type], item[:votable_id])
89
+ elsif item[:votable_gid]
90
+ fail 'GlobalID is not enabled.' unless defined?(::GlobalID)
91
+
92
+ # NOTE: Until GlobalID has patches or a plug-in to work with
93
+ # Sequel, this is more likely to fail than to succeed.
94
+ GlobalID::Locator.locate(item[:votable_gid])
95
+ end
96
+ end
97
+
98
+ votable if votable && votable.kind_of?(::Sequel::Model) &&
99
+ votable.ballot_votable?
100
+ end
101
+
102
+ # Return the id and canonical votable type name for the item, using
103
+ # #votable_for.
104
+ def votable_id_and_type_name_for(item)
105
+ __id_and_type_name(votable_for(item))
106
+ end
107
+
108
+ # Return a valid Voter object from the provided item or +nil+. Permitted
109
+ # values are a Voter, a hash with the key +:voter+, or a hash with the
110
+ # keys +:voter_type+ and +:voter_id+.
111
+ def voter_for(item)
112
+ voter =
113
+ if item.kind_of?(::Sequel::Plugins::BallotVoter::InstanceMethods)
114
+ item
115
+ elsif item.kind_of?(Hash)
116
+ if item[:voter]
117
+ item[:voter]
118
+ elsif item[:voter_type] && item[:voter_id]
119
+ __instance_of_model(item[:voter_type], item[:voter_id])
120
+ elsif item[:voter_gid]
121
+ fail 'GlobalID is not enabled.' unless defined?(::GlobalID)
122
+
123
+ # This should actually be GlobalID::Sequel::Locator when I
124
+ # get that ported.
125
+ GlobalID::Locator.locate(item[:voter_gid])
126
+ end
127
+ end
128
+
129
+ voter if voter && voter.kind_of?(::Sequel::Model) &&
130
+ voter.ballot_voter?
131
+ end
132
+
133
+ # Return the id and canonical voter type name for the item, using
134
+ # #voter_for.
135
+ def voter_id_and_type_name_for(item)
136
+ __id_and_type_name(voter_for(item))
137
+ end
138
+
139
+ private
140
+
141
+ # Return the id and canonical type name for the item.
142
+ def __id_and_type_name(item)
143
+ [ item.id, type_name(item) ] if item
144
+ end
145
+
146
+ def __instance_of_model(model, id)
147
+ constantize(model)[id]
148
+ rescue
149
+ nil
150
+ end
151
+
152
+ def constantize(s)
153
+ s = s.to_s
154
+ return s.constantize if s.respond_to?(:constantize)
155
+ unless (m = VALID_CONSTANT_NAME_REGEXP.match(s))
156
+ fail NameError, "#{s.inspect} is not a valid constant name!"
157
+ end
158
+ Object.module_eval("::#{m[1]}", __FILE__, __LINE__)
159
+ end
160
+
161
+ VALID_CONSTANT_NAME_REGEXP = /\A(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)\z/ #:nodoc:
162
+ private_constant :VALID_CONSTANT_NAME_REGEXP
163
+ end
164
+ end
165
+ end
166
+
167
+ unless Sequel::Model < Ballot::Sequel
168
+ Sequel::Model.send(:include, Ballot::Sequel)
169
+ Sequel::Model.extend Ballot::Sequel::ClassMethods
170
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ module Sequel
6
+ # The Sequel implementation of Ballot::Vote.
7
+ class Vote < ::Sequel::Model(:ballot_votes)
8
+ dataset_module do
9
+ subset(:up, vote: true) #:nodoc:
10
+ subset(:down, vote: false) #:nodoc:
11
+
12
+ def for_type(model_class) #:nodoc:
13
+ where(votable_type: Ballot::Sequel.type_name(model_class))
14
+ end
15
+
16
+ def by_type(model_class) #:nodoc:
17
+ where(voter_type: Ballot::Sequel.type_name(model_class))
18
+ end
19
+ end
20
+
21
+ plugin :validation_helpers
22
+ plugin :timestamps, update_on_create: true
23
+
24
+ votable_setter = ->(votable_instance) {
25
+ if votable_instance
26
+ self[:votable_id] = votable_instance.pk
27
+ self[:votable_type] = Ballot::Sequel.type_name(votable_instance)
28
+ end
29
+ }
30
+ votable_dataset = -> {
31
+ return if votable_type.nil? || votable_id.nil?
32
+ klass = self.class.send(:constantize, votable_type)
33
+ klass.where(klass.primary_key => votable_id)
34
+ }
35
+ votable_eager_loader = ->(eo) {
36
+ id_map = {}
37
+ eo[:rows].each do |model|
38
+ model.associations[:votable] = nil
39
+ next if model.votable_type.nil? || model.votable_id.nil?
40
+ ((id_map[model.votable_type] ||= {})[model.votable_id] ||= []) << model
41
+ end
42
+ id_map.each do |klass_name, ids|
43
+ klass = constantize(camelize(klass_name))
44
+ klass.where(klass.primary_key => ids.keys).all do |related_obj|
45
+ ids[related_obj.pk].each do |model|
46
+ model.associations[:votable] = related_obj
47
+ end
48
+ end
49
+ end
50
+ }
51
+
52
+ many_to_one :votable,
53
+ reciprocal: :votes,
54
+ reciprocal_type: :many_to_one,
55
+ setter: votable_setter,
56
+ dataset: votable_dataset,
57
+ eager_loader: votable_eager_loader
58
+
59
+ voter_setter = ->(voter_instance) {
60
+ if voter_instance
61
+ self[:voter_id] = voter_instance.pk
62
+ self[:voter_type] = Ballot::Sequel.type_name(voter_instance)
63
+ end
64
+ }
65
+ voter_dataset = -> {
66
+ return if voter_type.nil? || voter_id.nil?
67
+ klass = self.class.send(:constantize, voter_type)
68
+ klass.where(klass.primary_key => voter_id)
69
+ }
70
+ voter_eager_loader = ->(eo) {
71
+ id_map = {}
72
+ eo[:rows].each do |model|
73
+ model.associations[:voter] = nil
74
+ next if model.voter_type.nil? || model.voter_id.nil?
75
+ ((id_map[model.voter_type] ||= {})[model.voter_id] ||= []) << model
76
+ end
77
+ id_map.each do |klass_name, ids|
78
+ klass = constantize(camelize(klass_name))
79
+ klass.where(klass.primary_key => ids.keys).all do |related_obj|
80
+ ids[related_obj.pk].each do |model|
81
+ model.associations[:voter] = related_obj
82
+ end
83
+ end
84
+ end
85
+ }
86
+
87
+ many_to_one :voter,
88
+ reciprocal: :votes,
89
+ reciprocal_type: :many_to_one,
90
+ setter: voter_setter,
91
+ dataset: voter_dataset,
92
+ eager_loader: voter_eager_loader
93
+
94
+ def validate # :nodoc:
95
+ validates_presence %i(votable_id votable_type voter_id voter_type)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,445 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ # Methods added to a model that is marked as a Votable.
6
+ module Votable
7
+ ##
8
+ # If the model is caching the ballot summary (in a JSON-serialized column
9
+ # called +cached_ballot_summary+), we want to ensure that it is initialized
10
+ # to a Hash if it is not set.
11
+ def initialize(*)
12
+ super
13
+ self.cached_ballot_summary ||= {} if caching_ballot_summary?
14
+ end
15
+
16
+ ##
17
+ # Indicate whether a ballot has been registered on the current instance of
18
+ # this model. Votes are registered if and only if the vote is a *change*
19
+ # from the previous vote.
20
+ #
21
+ # post = Post.create(title: 'my amazing post')
22
+ # copy = post.dup
23
+ # post.down_ballot_by current_user
24
+ # post.ballot_registered? # => true
25
+ # copy.up_ballot_by current_user # => true
26
+ # copy.up_ballot_by_current_user # => false
27
+ # post.ballot_registered? # => true
28
+ def ballot_registered?
29
+ @ballot_registered
30
+ end
31
+
32
+ #-----
33
+ # :section: Recording Votes
34
+ #-----
35
+
36
+ ##
37
+ # :method: ballot_by
38
+ # :call-seq:
39
+ # ballot_by(voter = nil, kwargs = {})
40
+ # ballot_by(voter)
41
+ # ballot_by(voter_id: id, voter_type: type)
42
+ # ballot_by(voter_gid: gid)
43
+ # ballot_by(voter, scope: scope, vote: false, weight: true)
44
+ #
45
+ # Record a Vote for this Votable by the provided +voter+. The +voter+ may
46
+ # be specified as its own parameter, or through the keyword arguments
47
+ # +voter_id+, +voter_type+, +voter_gid+, or +voter+ (note that the
48
+ # parameter +voter+ will override the keyword argument +voter+, if both are
49
+ # provided).
50
+ #
51
+ # Additional named arguments may be provided through +kwargs+:
52
+ #
53
+ # scope:: The scope of the vote to be recorded. Defaults to +nil+.
54
+ # vote:: The vote to be recorded. Defaults to +true+ and is parsed through
55
+ # Ballot::Words.truthy?.
56
+ # weight:: The weight of the vote to be recorded. Defaults to +1+.
57
+ # duplicate:: Allow a duplicate vote to be recorded. This is not
58
+ # recommended as it has negative performance implications at
59
+ # scale.
60
+ #
61
+ # Other arguments are ignored.
62
+ #
63
+ # \ActiveRecord:: There are no special notes for ActiveRecord.
64
+ # \Sequel:: GlobalID does not currently provide support for Sequel. The use
65
+ # of +voter_gid+ in this case will probably fail.
66
+
67
+ ##
68
+ # Records a positive vote by the provided +voter+ with options provided in
69
+ # +kwargs+. Any value passed to the +vote+ keyword argument will be
70
+ # ignored. See #ballot_by for more details.
71
+ def up_ballot_by(voter = nil, kwargs = {})
72
+ ballot_by(voter, kwargs.merge(vote: true))
73
+ end
74
+
75
+ ##
76
+ # Records a negative vote by the provided +voter+ with options provided in
77
+ # +kwargs+. Any value passed to the +vote+ keyword argument will be
78
+ # ignored. See #ballot_by for more details.
79
+ def down_ballot_by(voter = nil, kwargs = {})
80
+ ballot_by(voter, kwargs.merge(vote: false))
81
+ end
82
+
83
+ ##
84
+ # :method: remove_ballot_by
85
+ # :call-seq:
86
+ # remove_ballot_by(voter = nil, kwargs = {})
87
+ # remove_ballot_by(voter)
88
+ # remove_ballot_by(voter_id: id, voter_type: type)
89
+ # remove_ballot_by(voter_gid: gid)
90
+ # remove_ballot_by(voter, scope: scope)
91
+ #
92
+ # Remove any votes for this Votable by the provided +voter+. The +voter+
93
+ # may be specified as its own parameter, or through the keyword arguments
94
+ # +voter_id+, +voter_type+, +voter_gid+, or +voter+ (note that the
95
+ # parameter +voter+ will override the keyword argument +voter+, if both are
96
+ # provided).
97
+ #
98
+ # Only the +scope+ argument is available through +kwargs+:
99
+ #
100
+ # scope:: The scope of the vote to be recorded. Defaults to +nil+.
101
+ #
102
+ # Other arguments are ignored.
103
+ #
104
+ # \ActiveRecord:: There are no special notes for \ActiveRecord.
105
+ # \Sequel:: GlobalID does not currently provide support for \Sequel, so
106
+ # there are many cases where attempting to use +voter_gid+ will
107
+ # fail.
108
+
109
+ #-----
110
+ # :section: Finding Votes
111
+ #-----
112
+
113
+ ##
114
+ # :method: ballots_for
115
+ #
116
+ # The votes attached to this Votable.
117
+ #
118
+ # \ActiveRecord:: This is generated by the polymorphic association
119
+ # <tt>has_many :ballots_for</tt>.
120
+ # \Sequel:: This is generated by the polymorphic association
121
+ # <tt>one_to_many :ballots_for</tt>
122
+
123
+ ##
124
+ # :method: ballots_for_dataset
125
+ #
126
+ # The \Sequel association dataset for votes attached to this Votable.
127
+ #
128
+ # \ActiveRecord:: This does not exist for \ActiveRecord.
129
+ # \Sequel:: This is generated by the polymorphic association
130
+ # <tt>one_to_many :ballots_for</tt>
131
+
132
+ ##
133
+ # Returns ballots for this Votable where the recorded vote is positive.
134
+ #
135
+ # \ActiveRecord:: There are no special notes for ActiveRecord.
136
+ # \Sequel:: This method returns the _dataset_; if vote objects are desired,
137
+ # use <tt>up_ballots_for.all</tt>.
138
+ def up_ballots_for(scope: nil)
139
+ find_ballots_for(vote: true, scope: scope)
140
+ end
141
+
142
+ ##
143
+ # Returns ballots for this Votable where the recorded vote is negative.
144
+ #
145
+ # \ActiveRecord:: There are no special notes for ActiveRecord.
146
+ # \Sequel:: This method returns the _dataset_; if vote objects are desired,
147
+ # use <tt>down_ballots_for.all</tt>.
148
+ def down_ballots_for(scope: nil)
149
+ find_ballots_for(vote: false, scope: scope)
150
+ end
151
+
152
+ #-----
153
+ # :section: Voter Inquiries
154
+ #-----
155
+
156
+ ##
157
+ # :method: ballot_by?
158
+ # :call-seq:
159
+ # ballot_by?(voter = nil, kwargs = {})
160
+ # ballot_by?(voter)
161
+ # ballot_by?(voter_id: id, voter_type: type)
162
+ # ballot_by?(voter_gid: gid)
163
+ # ballot_by?(voter, scope: scope, vote: false, weight: true)
164
+ #
165
+ # Returns +true+ if the provided +voter+ has made votes for this Votable
166
+ # matching the provided criteria. The +voter+ may be specified as its own
167
+ # parameter, or through the keyword arguments +voter_id+, +voter_type+,
168
+ # +voter_gid+, or +voter+ (note that the parameter +voter+ will override
169
+ # the keyword argument +voter+, if both are provided).
170
+ #
171
+ # Additional named arguments may be provided through +kwargs+:
172
+ #
173
+ # scope:: The scope of the vote to be recorded. Defaults to +nil+.
174
+ # vote:: The vote to be queried. If present, is parsed through
175
+ # Ballot::Words.truthy?.
176
+ #
177
+ # Other arguments are ignored.
178
+ #
179
+ # \ActiveRecord:: There are no special notes for ActiveRecord.
180
+ # \Sequel:: GlobalID does not currently provide support for Sequel. The use
181
+ # of +voter_gid+ in this case will probably fail.
182
+
183
+ ##
184
+ # Returns +true+ if the provided +voter+ has made positive votes for this
185
+ # Votable. Any value passed to the +vote+ keyword argument will be ignored.
186
+ # See #ballot_by? for more details.
187
+ def up_ballot_by?(voter = nil, kwargs = {})
188
+ ballot_by?(voter, kwargs.merge(vote: true))
189
+ end
190
+
191
+ ##
192
+ # Returns +true+ if the provided +voter+ has made negative votes for this
193
+ # Votable. Any value passed to the +vote+ keyword argument will be ignored.
194
+ # See #ballot_by? for more details.
195
+ def down_ballot_by?(voter = nil, kwargs = {})
196
+ ballot_by?(voter, kwargs.merge(vote: false))
197
+ end
198
+
199
+ ##
200
+ # :method: ballots_by_class(model_class, kwargs = {})
201
+ #
202
+ # Find ballots cast for this Votable matching the canonical name of the
203
+ # +model_class+ as the type of Voter.
204
+ #
205
+ # Additional named arguments may be provided through +kwargs+:
206
+ #
207
+ # scope:: The scope of the vote to be recorded. Defaults to +nil+.
208
+ # vote:: The vote to be queried. If present, is parsed through
209
+ # Ballot::Words.truthy?.
210
+ #
211
+ # Other arguments are ignored.
212
+
213
+ ##
214
+ # Find positive ballots cast by this Voter matching the canonical name of
215
+ # the +model_class+ as the type of Voter. Any value passed to the +vote+
216
+ # keyword argument will be ignored. See #ballots_by_class for more details.
217
+ def up_ballots_by_class(model_class, kwargs = {})
218
+ ballots_by_class(model_class, kwargs.merge(vote: true))
219
+ end
220
+
221
+ ##
222
+ # Find negative ballots cast by this Voter matching the canonical name of
223
+ # the +model_class+ as the type of Voter. Any value passed to the +vote+
224
+ # keyword argument will be ignored. See #ballots_by_class for more details.
225
+ def down_ballots_by_class(model_class, kwargs = {})
226
+ ballots_by_class(model_class, kwargs.merge(vote: false))
227
+ end
228
+
229
+ ##
230
+ # Returns the Voter objects that have made votes on this Votable.
231
+ # Additional query conditions may be specified in +conds+, or in the
232
+ # +block+ if supported by the ORM. The Voter objects are eager loaded to
233
+ # minimize the number of queries required to satisfy this request.
234
+ #
235
+ # \ActiveRecord:: Polymorphic eager loading is directly supported, using
236
+ # <tt>ballots_for.includes(:voter)</tt>. Normal
237
+ # +where+-clause conditions may be provided in +conds+.
238
+ # \Sequel:: Polymorphic eager loading is not supported by \Sequel, but has
239
+ # been implemented in Ballot for this method. Normal
240
+ # +where+-clause conditions may be provided in +conds+ or in
241
+ # +block+ for \Sequel virtual row support.
242
+ def ballot_voters(*conds, &block)
243
+ __eager_ballot_voters(find_ballots_for(*conds, &block))
244
+ end
245
+
246
+ ##
247
+ # Returns the Voter objects that have made positive votes on this Votable.
248
+ # See #ballot_voters for how +conds+ and +block+ apply.
249
+ def up_ballot_voters(*conds, &block)
250
+ __eager_ballot_voters(
251
+ find_ballots_for(*conds, &block).where(vote: true)
252
+ )
253
+ end
254
+
255
+ ##
256
+ # Returns the Voter objects that have made negative votes on this Votable.
257
+ # See #ballot_voters for how +conds+ and +block+ apply.
258
+ def down_ballot_voters(*conds, &block)
259
+ __eager_ballot_voters(
260
+ find_ballots_for(*conds, &block).where(vote: false)
261
+ )
262
+ end
263
+
264
+ #-----
265
+ # :section: Ballot Summaries and Caching
266
+ #-----
267
+
268
+ ##
269
+ # :attr_accessor: cached_ballot_summary
270
+ #
271
+ # A Hash object used for caching balloting summaries for this Votable. When
272
+ # caching is enabled, all scopes and values are cached. For each scope,
273
+ # this caches:
274
+ #
275
+ # * The total number of ballots cast (#total_ballots);
276
+ # * The total number of positive (up) ballots cast (#total_up_ballots);
277
+ # * The total number of negative (down) ballots cast (#total_down_ballots);
278
+ # * The ballot score (number of up ballots less the number of down ballots,
279
+ # #ballot_score);
280
+ # * The weighted ballot total (sum of ballot weights,
281
+ # #weighted_ballot_total);
282
+ # * The weighted ballot score (the sum of up ballot weights less the sum of
283
+ # down ballot weights; #weighted_ballot_score);
284
+ # * The weighted ballot average (the weighted ballot score over the number
285
+ # of votes, #weighted_ballot_average).
286
+ #
287
+ # <em>Present only if the column +cached_ballot_summary+ exists on
288
+ # the underlying Votable.</em>
289
+
290
+ ##
291
+ # The total number of ballots cast for this Votable in the provided
292
+ # +scope+. If +scope+ is not provided, reports for the _default_ scope.
293
+ #
294
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
295
+ # +false+, returns the cached total.
296
+ def total_ballots(scope = nil, skip_cache: false)
297
+ if !skip_cache && caching_ballot_summary?
298
+ scoped_cache_summary(scope)['total'].to_i
299
+ else
300
+ find_ballots_for(scope: scope).count
301
+ end
302
+ end
303
+
304
+ ##
305
+ # The total number of positive ballots cast for this Votable in the
306
+ # provided +scope+. If +scope+ is not provided, reports for the _default_
307
+ # scope.
308
+ #
309
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
310
+ # +false+, returns the cached total.
311
+ def total_up_ballots(scope = nil, skip_cache: false)
312
+ if !skip_cache && caching_ballot_summary?
313
+ scoped_cache_summary(scope)['up'].to_i
314
+ else
315
+ up_ballots_for(scope: scope).count
316
+ end
317
+ end
318
+
319
+ ##
320
+ # The total number of negative ballots cast for this Votable in the
321
+ # provided +scope+. If +scope+ is not provided, reports for the _default_
322
+ # scope.
323
+ #
324
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
325
+ # +false+, returns the cached total.
326
+ def total_down_ballots(scope = nil, skip_cache: false)
327
+ if !skip_cache && caching_ballot_summary?
328
+ scoped_cache_summary(scope)['down'].to_i
329
+ else
330
+ down_ballots_for(scope: scope).count
331
+ end
332
+ end
333
+
334
+ ##
335
+ # The computed score of ballots cast (total positive ballots less total
336
+ # negative ballots) for this Votable in the provided +scope+. If +scope+ is
337
+ # not provided, reports for the _default_ scope.
338
+ #
339
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
340
+ # +false+, returns the cached score.
341
+ def ballot_score(scope = nil, skip_cache: false)
342
+ if !skip_cache && caching_ballot_summary?
343
+ scoped_cache_summary(scope)['score'].to_i
344
+ else
345
+ total_up_ballots(scope, skip_cache: skip_cache) -
346
+ total_down_ballots(scope, skip_cache: skip_cache)
347
+ end
348
+ end
349
+
350
+ ##
351
+ # The weighted total of ballots cast (the sum of all ballot +weights+) for
352
+ # this Votable in the provided +scope+. If +scope+ is not provided, reports
353
+ # for the _default_ scope.
354
+ #
355
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
356
+ # +false+, returns the cached score.
357
+ def weighted_ballot_total(scope = nil, skip_cache: false)
358
+ if !skip_cache && caching_ballot_summary?
359
+ scoped_cache_summary(scope)['weighted_ballot_total'].to_i
360
+ else
361
+ find_ballots_for(scope: scope).sum(:weight).to_i
362
+ end
363
+ end
364
+
365
+ ##
366
+ # The weighted score of ballots cast (the sum of all positive ballot
367
+ # +weight+s less the sum of all negative ballot +weight+s) for this Votable
368
+ # in the provided +scope+. If +scope+ is not provided, reports for the
369
+ # _default_ scope.
370
+ #
371
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
372
+ # +false+, returns the cached score.
373
+ def weighted_ballot_score(scope = nil, skip_cache: false)
374
+ if !skip_cache && caching_ballot_summary?
375
+ scoped_cache_summary(scope)['weighted_ballot_score'].to_i
376
+ else
377
+ up_ballots_for(scope: scope).sum(:weight).to_i -
378
+ down_ballots_for(scope: scope).sum(:weight).to_i
379
+ end
380
+ end
381
+
382
+ ##
383
+ # The weighted average of ballots cast (the weighted ballot score over the
384
+ # total number of votes cast) for this Votable in the provided +scope+. If
385
+ # +scope+ is not provided, reports for the _default_ scope.
386
+ #
387
+ # If the Votable has a +cached_ballot_summary+ column and +skip_cache+ is
388
+ # +false+, returns the cached average.
389
+ def weighted_ballot_average(scope = nil, skip_cache: false)
390
+ if !skip_cache && caching_ballot_summary?
391
+ scoped_cache_summary(scope)['weighted_ballot_average'].to_i
392
+ elsif (count = total_ballots) > 0
393
+ weighted_ballot_score.to_f / count
394
+ else
395
+ 0.0
396
+ end
397
+ end
398
+
399
+ private
400
+
401
+ attr_writer :ballot_registered
402
+
403
+ def calculate_summary(scope = nil)
404
+ {}.tap do |summary|
405
+ summary[scope] ||= {}
406
+ summary[scope]['total'] = total_ballots(scope, skip_cache: true)
407
+ summary[scope]['up'] = total_up_ballots(scope, skip_cache: true)
408
+ summary[scope]['down'] = total_down_ballots(scope, skip_cache: true)
409
+ summary[scope]['score'] = ballot_score(scope, skip_cache: true)
410
+ summary[scope]['weighted_ballot_total'] =
411
+ weighted_ballot_total(scope, skip_cache: true)
412
+ summary[scope]['weighted_ballot_score'] =
413
+ weighted_ballot_score(scope, skip_cache: true)
414
+ summary[scope]['weighted_ballot_average'] =
415
+ weighted_ballot_average(scope, skip_cache: true)
416
+ end
417
+ end
418
+
419
+ def scoped_cache_summary(scope = nil)
420
+ if scope.nil?
421
+ cached_ballot_summary.fetch(scope) { cached_ballot_summary.fetch('') { {} } }
422
+ else
423
+ cached_ballot_summary[scope] || {}
424
+ end
425
+ end
426
+
427
+ def __ballot_votable_kwargs(voter, kwargs)
428
+ if voter.kind_of?(Hash)
429
+ kwargs.merge(voter)
430
+ elsif voter.nil?
431
+ kwargs
432
+ else
433
+ kwargs.merge(voter: voter)
434
+ end
435
+ end
436
+
437
+ # Methods added to the Votable model class.
438
+ module ClassMethods
439
+ # The class is now a votable record.
440
+ def ballot_votable?
441
+ true
442
+ end
443
+ end
444
+ end
445
+ end