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