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,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