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