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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ module Ballot
5
+ # The namespace for Ballot generators for Rails.
6
+ module Generators
7
+ ##
8
+ # :attr_reader:
9
+ # The ORM to use when generating the migrations.
10
+ def orm
11
+ if defined?(::Rails::Generators.options)
12
+ ::Rails::Generators.options[:rails][:orm]
13
+ else
14
+ @orm || :active_record
15
+ end
16
+ end
17
+
18
+ ##
19
+ # Set the ORM to use when generating the migrations. Ignored under Rails.
20
+ attr_writer :orm
21
+
22
+ # The source root for the generator templates.
23
+ def source_root
24
+ File.expand_path(
25
+ File.join('..', 'ballot', generator_name, 'templates', orm.to_s),
26
+ __FILE__
27
+ )
28
+ end
29
+
30
+ # Indicates whether the ORM is supported by these generators.
31
+ def orm_has_migration?
32
+ %i(active_record sequel).include? orm
33
+ end
34
+
35
+ # The next migration number.
36
+ def next_migration_number(_path)
37
+ Time.now.utc.strftime('%Y%m%d%H%M%S')
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'generators/ballot'
4
+
5
+ ##
6
+ module Ballot
7
+ module Generators
8
+ # The Rails generator to install the ballot_votes table.
9
+ class InstallGenerator < ::Rails::Generators::Base
10
+ include ::Rails::Generators::Migration if defined?(::Rails::Generators::Migration)
11
+ extend Ballot::Generators
12
+
13
+ desc <<-DESC
14
+ Description:
15
+ Create the ballot_votes migration.
16
+ DESC
17
+
18
+ def create_migration_file #:nodoc:
19
+ if self.class.orm_has_migration?
20
+ migration_template 'migration.rb', 'db/migrate/install_ballot_vote_migration.rb'
21
+ else
22
+ warn "Unsupported ORM #{self.class.orm}"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InstallBallotVoteMigration < ActiveRecord::Migration
4
+ def change
5
+ create_table :ballot_votes do |t|
6
+ t.references :votable, polymorphic: true
7
+ t.references :voter, polymorphic: true
8
+
9
+ t.boolean :vote, null: false, default: true
10
+ t.string :scope
11
+ t.integer :weight
12
+
13
+ t.timestamps null: false
14
+ end
15
+
16
+ add_index :ballot_votes, %i(voter_id voter_type scope)
17
+ add_index :ballot_votes, %i(votable_id votable_type scope)
18
+ end
19
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ create_table :ballot_votes do
6
+ primary_key :id
7
+
8
+ Integer :votable_id
9
+ String :votable_type
10
+
11
+ Integer :voter_id
12
+ String :voter_type
13
+
14
+ Boolean :vote, null: false, default: true
15
+ String :scope
16
+ Integer :weight
17
+
18
+ DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
19
+ DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
20
+
21
+ add_index %i(votable_type votable_id scope), name: :ballot_votes_votable_by_scope
22
+ add_index %i(voter_type voter_id scope), name: :ballot_votes_voter_by_scope
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ #:nocov:
6
+
7
+ ##
8
+ module Ballot
9
+ module Generators
10
+ # The Ballot standalone generator.
11
+ class Standalone
12
+ # Create and run the standalone generator with the command-line arguments.
13
+ def self.run(argv)
14
+ new(argv).run
15
+ end
16
+
17
+ # The arguments provided to the Standalone generator.
18
+ attr_reader :argv
19
+
20
+ # Create the standalone generator.
21
+ def initialize(argv)
22
+ @argv = argv
23
+ end
24
+
25
+ # Run the standalone generator.
26
+ def run
27
+ op = OptionParser.new { |opts|
28
+ opts.banner = 'Usage: ballot_generator [options]'
29
+
30
+ opts.on('--orm ORM', %w(active_record sequel), 'Select the ORM') do |orm|
31
+ @orm = orm.to_sym
32
+ end
33
+ opts.on(
34
+ '--install', '-I',
35
+ Ballot::Generators::InstallGenerator.desc
36
+ ) do
37
+ if generator && !generator.kind_of?(Ballot::Generators::InstallGenerator)
38
+ warn 'Can only select one generator to run.'
39
+ $stderr.puts opts
40
+ return 1
41
+ else
42
+ self.generator = Ballot::Generators::InstallGenerator.new
43
+ end
44
+ end
45
+ opts.on(
46
+ '--summary NAME', '-S',
47
+ Ballot::Generators::SummaryGenerator.desc
48
+ ) do |name|
49
+ if generator && !generator.kind_of?(Ballot::Generators::SummaryGenerator)
50
+ warn 'Can only select one generator to run.'
51
+ $stderr.puts opts
52
+ return 1
53
+ else
54
+ self.generator = Ballot::Generators::SummaryGenerator.new(name)
55
+ end
56
+ end
57
+
58
+ opts.on('-h', '--help', 'Prints this help') do
59
+ $stdout.puts opts
60
+ return 1
61
+ end
62
+ }
63
+ op.parse!(argv)
64
+
65
+ if generator
66
+ generator.class.orm = @orm if @orm
67
+ generator.create_migration_file
68
+ else
69
+ $stdout.puts op
70
+ end
71
+
72
+ 0
73
+ rescue => ex
74
+ $stderr.puts ex.message
75
+ return 1
76
+ end
77
+
78
+ private
79
+
80
+ attr_accessor :generator
81
+ end
82
+ end
83
+ end
84
+
85
+ require_relative 'standalone/support'
86
+ require_relative 'install/install_generator'
87
+ require_relative 'summary/summary_generator'
88
+
89
+ #:nocov:
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ #:nocov:
6
+ #:stopdoc:
7
+
8
+ # Everything in this file is wrong, except that it does the right thing to
9
+ # simplify the overall implementation of standalone generators.
10
+ module Rails
11
+ module Generators
12
+ # Implement just enough of Generators to be useful.
13
+ class Base #:nodoc:
14
+ def self.desc(value = nil)
15
+ @desc = value.gsub(/^Description:\n\s+/, '').chomp if value
16
+ @desc
17
+ end
18
+
19
+ def self.generator_name
20
+ name.split(/::/).last.sub(/Generator/, '').downcase
21
+ end
22
+
23
+ attr_reader :argv
24
+ attr_accessor :destination
25
+
26
+ def migration_template(source, target)
27
+ data = File.read(File.join(self.class.source_root, source))
28
+ data = ERB.new(data, 0, '%<>>-').result(binding)
29
+
30
+ path, file = File.split(target)
31
+
32
+ file = "#{self.class.next_migration_number(nil)}_#{file}"
33
+
34
+ File.write(File.join(destination || path, file), data)
35
+ end
36
+ end
37
+
38
+ class NamedBase < Base #:nodoc:
39
+ def initialize(name)
40
+ @name = prepare_name(name)
41
+ end
42
+
43
+ def plural_table_name
44
+ "#{@name}s"
45
+ end
46
+
47
+ def class_name
48
+ @name.gsub(/^(.)|_(.)/) { (Regexp.last_match(1) || Regexp.last_match(2)).upcase }
49
+ end
50
+
51
+ def file_name
52
+ @name
53
+ end
54
+
55
+ private
56
+
57
+ def prepare_name(name)
58
+ name.split(/::/).
59
+ last.
60
+ gsub(/([A-Z])/, '_\1').
61
+ downcase.
62
+ sub(/^_/, '').
63
+ sub(/s$/, '')
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ #:startdoc:
70
+ #:nocov:
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'generators/ballot'
4
+
5
+ ##
6
+ module Ballot
7
+ module Generators
8
+ # The Rails generator to install the cache_ballot_summary column on the
9
+ # indicated table.
10
+ class SummaryGenerator < ::Rails::Generators::NamedBase
11
+ include ::Rails::Generators::Migration if defined?(::Rails::Generators::Migration)
12
+ extend Ballot::Generators
13
+
14
+ desc <<-DESC
15
+ Description:
16
+ Create a migration to add cached ballot summaries to the named table.
17
+ DESC
18
+
19
+ def create_migration_file #:nodoc:
20
+ if self.class.orm_has_migration?
21
+ migration_template 'migration.rb',
22
+ "db/migrate/ballot_cache_for_#{file_name}.rb"
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BallotCacheFor<%= class_name %> < ActiveRecord::Migration
4
+ def change
5
+ change_table :'<%= plural_table_name %>' do |t|
6
+ if t.respond_to?(:jsonb)
7
+ t.jsonb :cached_ballot_summary, null: false, default: {}
8
+ elsif t.respond_to?(:json)
9
+ t.json :cached_ballot_summary, null: false, default: {}
10
+ else
11
+ t.text :cached_ballot_summary, null: false, default: '{}'
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ change do
5
+ types = %i(jsonb json String)
6
+ column_options = {
7
+ null: false,
8
+ default: '{}'
9
+ }
10
+
11
+ alter_table :'<%= plural_table_name %>' do
12
+ begin
13
+ type = types.shift
14
+ add_column :cached_ballot_summary, type, column_options
15
+ rescue
16
+ types.empty? && raise || retry
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sequel # :nodoc:
4
+ module Plugins # :nodoc:
5
+ # The votable plugin marks the model as containing objects that can be
6
+ # voted on. It creates a polymorphic one-to-many relationship from the
7
+ # Votable model to Ballot::Sequel::Vote.
8
+ #
9
+ # This may be used with single_table_inheritance, but should be loaded
10
+ # *after* single_table_inheritance has been called. It has not been tested
11
+ # with class_table_inheritance.
12
+ #
13
+ # This plug-in causes Ballot::Votable to be included into the affected
14
+ # model, and Ballot::Votable::ClassMethods to be extended onto the affected
15
+ # model.
16
+ module BallotVotable
17
+ def self.apply(model) # :nodoc:
18
+ require 'ballot/sequel/vote'
19
+ require 'ballot/words'
20
+ require 'ballot/votable'
21
+
22
+ model.instance_eval do
23
+ if columns.include?(:cached_ballot_summary)
24
+ plugin :serialization, :json, :cached_ballot_summary
25
+ end
26
+
27
+ # Create a polymorphic one-to-many relationship for votables. Based
28
+ # heavily on the one_to_many implementation from sequel_polymorphic,
29
+ # but customized to sequel-voting's needs.
30
+ one_to_many :ballots_for,
31
+ key: :votable_id,
32
+ reciprocal: :votable,
33
+ reciprocal_type: :one_to_many,
34
+ conditions: { votable_type: Ballot::Sequel.type_name(model) },
35
+ adder: ->(many_of_instance) {
36
+ many_of_instance.update(
37
+ votable_id: pk,
38
+ votable_type: Ballot::Sequel.type_name(model)
39
+ )
40
+ },
41
+ remover: ->(many_of_instance) { many_of_instance.delete },
42
+ clearer: -> { ballots_for_dataset.delete },
43
+ class: '::Ballot::Sequel::Vote'
44
+
45
+ include Ballot::Votable
46
+ extend Ballot::Votable::ClassMethods
47
+ end
48
+ end
49
+
50
+ module InstanceMethods #:nodoc:
51
+ def ballot_by(voter = nil, kwargs = {}) #:nodoc:
52
+ kwargs = { vote: true, scope: nil }.
53
+ merge(__ballot_votable_kwargs(voter, kwargs))
54
+ self.ballot_registered = false
55
+
56
+ voter_id, voter_type = Ballot::Sequel.voter_id_and_type_name_for(kwargs)
57
+ return false unless voter_id
58
+
59
+ votes_ = find_ballots_for(
60
+ scope: kwargs[:scope],
61
+ voter_id: voter_id,
62
+ voter_type: voter_type
63
+ )
64
+
65
+ vote = if votes_.none? || kwargs[:duplicate]
66
+ Ballot::Sequel::Vote.new(
67
+ votable_id: id,
68
+ votable_type: Ballot::Sequel.type_name(model),
69
+ voter_id: voter_id,
70
+ voter_type: voter_type,
71
+ scope: kwargs[:scope]
72
+ )
73
+ else
74
+ votes_.last
75
+ end
76
+
77
+ flag = Ballot::Words.truthy?(kwargs[:vote])
78
+ weight = kwargs[:weight] && kwargs[:weight].to_i || 1
79
+
80
+ return false if vote.vote == flag && vote.weight == weight
81
+
82
+ vote.vote = flag
83
+ vote.weight = weight
84
+
85
+ model.db.transaction do
86
+ if vote.save
87
+ self.ballot_registered = true
88
+ update_cached_votes kwargs[:scope]
89
+ true
90
+ end
91
+ end
92
+ end
93
+
94
+ def remove_ballot_by(voter = nil, kwargs = {}) # :nodoc:
95
+ kwargs = __ballot_votable_kwargs(voter, kwargs)
96
+ voter_id, voter_type = Ballot::Sequel.voter_id_and_type_name_for(kwargs)
97
+ return false unless voter_id
98
+
99
+ votes_ = find_ballots_for(
100
+ scope: kwargs[:scope],
101
+ voter_id: voter_id,
102
+ voter_type: voter_type
103
+ )
104
+
105
+ return true if votes_.none?
106
+
107
+ model.db.transaction do
108
+ votes_.each(&:destroy)
109
+ update_cached_votes kwargs[:scope]
110
+ self.ballot_registered = ballots_for_dataset.any?
111
+ true
112
+ end
113
+ end
114
+
115
+ def ballot_by?(voter = nil, kwargs = {}) #:nodoc:
116
+ kwargs = __ballot_votable_kwargs(voter, kwargs)
117
+ voter_id, voter_type = Ballot::Sequel.voter_id_and_type_name_for(kwargs)
118
+ return false unless voter_id
119
+
120
+ cond = {
121
+ voter_id: voter_id,
122
+ voter_type: voter_type,
123
+ scope: kwargs[:scope]
124
+ }
125
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
126
+
127
+ find_ballots_for(cond).any?
128
+ end
129
+
130
+ def ballots_by_class(klass, kwargs = {}) #:nodoc:
131
+ cond = {
132
+ voter_type: Ballot::Sequel.type_name(klass),
133
+ scope: kwargs[:scope]
134
+ }
135
+ cond[:vote] = Ballot::Words.truthy?(kwargs[:vote]) if kwargs.key?(:vote)
136
+
137
+ find_ballots_for(cond)
138
+ end
139
+
140
+ private
141
+
142
+ def caching_ballot_summary?
143
+ model.columns.include?(:cached_ballot_summary)
144
+ end
145
+
146
+ def find_ballots_for(*cond, &block)
147
+ ballots_for_dataset.where(*cond, &block)
148
+ end
149
+
150
+ def update_cached_votes(scope = nil)
151
+ return false unless caching_ballot_summary?
152
+
153
+ lock!
154
+ summary = cached_ballot_summary.merge(calculate_summary(scope))
155
+ self.cached_ballot_summary = summary
156
+ save_changes
157
+ end
158
+
159
+ def __eager_ballot_voters(ds)
160
+ ballots = ds.naked.select(:voter_type, :voter_id).all
161
+ partitioned = ballots.group_by { |e| e[:voter_type] }
162
+ partitioned.each_value do |value|
163
+ value.map! { |e| e[:voter_id] }
164
+ end
165
+ partitioned.each_key do |key|
166
+ voters = self.class.send(:constantize, key).
167
+ where(id: partitioned[key]).
168
+ map { |v| [ v.id, v ] }
169
+
170
+ partitioned[key] = Hash[voters]
171
+ end
172
+
173
+ ballots.map { |ballot|
174
+ partitioned[ballot[:voter_type]][ballot[:voter_id]]
175
+ }
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end