voting 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE +21 -0
  4. data/README.md +229 -0
  5. data/lib/generators/voting/install_generator.rb +19 -0
  6. data/lib/generators/voting/templates/db/migrate/create_voting_tables.rb +35 -0
  7. data/lib/voting.rb +10 -0
  8. data/lib/voting/models/voting/extension.rb +85 -0
  9. data/lib/voting/models/voting/vote.rb +45 -0
  10. data/lib/voting/models/voting/voting.rb +73 -0
  11. data/lib/voting/version.rb +5 -0
  12. data/spec/factories/article.rb +5 -0
  13. data/spec/factories/author.rb +5 -0
  14. data/spec/factories/category.rb +5 -0
  15. data/spec/factories/comment.rb +5 -0
  16. data/spec/factories/voting/vote.rb +8 -0
  17. data/spec/factories/voting/voting.rb +9 -0
  18. data/spec/models/extension/after_save_spec.rb +37 -0
  19. data/spec/models/extension/as_spec.rb +26 -0
  20. data/spec/models/extension/down_spec.rb +26 -0
  21. data/spec/models/extension/order_by_voting_spec.rb +94 -0
  22. data/spec/models/extension/up_spec.rb +26 -0
  23. data/spec/models/extension/vote_for_spec.rb +26 -0
  24. data/spec/models/extension/vote_spec.rb +26 -0
  25. data/spec/models/extension/voted_question_spec.rb +38 -0
  26. data/spec/models/extension/voted_records_spec.rb +12 -0
  27. data/spec/models/extension/voted_spec.rb +40 -0
  28. data/spec/models/extension/votes_records_spec.rb +12 -0
  29. data/spec/models/extension/votes_spec.rb +40 -0
  30. data/spec/models/extension/voting_records_spec.rb +12 -0
  31. data/spec/models/extension/voting_spec.rb +40 -0
  32. data/spec/models/extension/voting_warm_up_spec.rb +115 -0
  33. data/spec/models/vote/create_spec.rb +273 -0
  34. data/spec/models/vote/vote_for_spec.rb +40 -0
  35. data/spec/models/vote_spec.rb +27 -0
  36. data/spec/models/voting/update_voting_spec.rb +28 -0
  37. data/spec/models/voting/values_data_spec.rb +24 -0
  38. data/spec/models/voting_spec.rb +26 -0
  39. data/spec/rails_helper.rb +11 -0
  40. data/spec/support/common.rb +22 -0
  41. data/spec/support/database_cleaner.rb +19 -0
  42. data/spec/support/db/migrate/create_articles_table.rb +7 -0
  43. data/spec/support/db/migrate/create_authors_table.rb +7 -0
  44. data/spec/support/db/migrate/create_categories_table.rb +9 -0
  45. data/spec/support/db/migrate/create_comments_table.rb +7 -0
  46. data/spec/support/factory_bot.rb +9 -0
  47. data/spec/support/migrate.rb +11 -0
  48. data/spec/support/models/article.rb +7 -0
  49. data/spec/support/models/author.rb +5 -0
  50. data/spec/support/models/category.rb +5 -0
  51. data/spec/support/models/comment.rb +5 -0
  52. data/spec/support/shared_context/with_database_records.rb +22 -0
  53. data/spec/support/shoulda.rb +10 -0
  54. metadata +257 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a4aa7764b2a518434df6182d2f0a0101b9d3079c
4
+ data.tar.gz: e6a96257d3a5f67a6b8224f21d538285db21c79c
5
+ SHA512:
6
+ metadata.gz: '01686bdfc40e30e8ce89208e3f3b2e21425382abc2fb7f9dee4f9f2205e5b63886e8564703198d2e6cae610866a839aa8bfa0352cb00ac5d46fe80e1104096ae'
7
+ data.tar.gz: d3aa1a7f97da1fa5b2c145e7887beb74ab8277007e89b71a70d9baee8a4bc94138c1236c2e4c6e4c58f1e17ea6b5d744794ecf51d6bd8e3f40623a6b106f80e1
@@ -0,0 +1,3 @@
1
+ ## v0.1.0
2
+
3
+ - First release.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2018 Washington Botelho
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,229 @@
1
+ # Voting
2
+
3
+ [![Build Status](https://travis-ci.org/wbotelhos/voting.svg)](https://travis-ci.org/wbotelhos/voting)
4
+ [![Gem Version](https://badge.fury.io/rb/voting.svg)](https://badge.fury.io/rb/voting)
5
+ [![LiberPay](https://img.shields.io/badge/donate-%3C3-brightgreen.svg)](https://liberapay.com/wbotelhos)
6
+
7
+ A Binomial proportion confidence interval voting system with scope and cache enabled.
8
+
9
+ ## Description
10
+
11
+ Voting uses **Binomial proportion confidence interval** to calculate the voting. Inspired on [Evan Miller Article](https://www.evanmiller.org/how-not-to-sort-by-average-rating.html) and used by [Reddit](https://redditblog.com/2009/10/15/reddits-new-comment-sorting-system), [Yelp](https://www.yelpblog.com/2011/02/the-most-romantic-city-on-yelp-is), [Digg](web.archive.org/web/20110415020820/http://about.digg.com/blog/algorithm-experiments-better-comments) and probably other, Voting gives you cache and option to work with scopes over a **binary** voting system.
12
+
13
+ If you are looking for **5 stars** voting system, check [Rating](https://github.com/wbotelhos/rating) :star2:
14
+
15
+ ## Install
16
+
17
+ Add the following code on your `Gemfile` and run `bundle install`:
18
+
19
+ ```ruby
20
+ gem 'voting'
21
+ ```
22
+
23
+ Run the following task to create a Voting migration:
24
+
25
+ ```bash
26
+ rails g voting:install
27
+ ```
28
+
29
+ Then execute the migrations to create the tables `voting_votes` and `voting_votings`:
30
+
31
+ ```bash
32
+ rake db:migrate
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Just add the callback `voting` to your model:
38
+
39
+ ```ruby
40
+ class Author < ApplicationRecord
41
+ voting
42
+ end
43
+ ```
44
+
45
+ Now this model can vote or receive votes.
46
+
47
+ ### vote
48
+
49
+ You can vote on some resource:
50
+
51
+ ```ruby
52
+ author_1 = Author.first
53
+ author_2 = Author.last
54
+ resource = Comment.last
55
+
56
+ author_1.vote(resource, 1) # +1 vote
57
+ author_2.vote(resource, -1) # -1 vote
58
+ ```
59
+
60
+ ### voting
61
+
62
+ A voted resource exposes a cached data about it state:
63
+
64
+ ```ruby
65
+ resource = Comment.last
66
+
67
+ resource.voting
68
+ ```
69
+
70
+ It will return a `Voting::Voting` object that keeps:
71
+
72
+ `author`: the author of this vote;
73
+
74
+ `estimate`: the Binomial proportion confidence interval value;
75
+
76
+ `negative`: the sum of negative votes for this resource;
77
+
78
+ `positive`: the sum of positive votes for this resource;
79
+
80
+ `resource`: the self object that received this vote;
81
+
82
+ `scopeable`: the object used as scope;
83
+
84
+ ### vote_for
85
+
86
+ You can retrieve the vote an author gave to a specific resource:
87
+
88
+ ```ruby
89
+ author = Author.last
90
+ resource = Comment.last
91
+
92
+ author.vote_for resource
93
+ ```
94
+
95
+ It will return a `Voting::Vote` object that keeps:
96
+
97
+ `author`: the author of vote;
98
+
99
+ `resource`: the resource that received the vote;
100
+
101
+ `negative`: the -1 value when vote was negative;
102
+
103
+ `positive`: the 1 value when vote was positive;
104
+
105
+ `scopeable`: the object used as scope;
106
+
107
+ ### voted?
108
+
109
+ Maybe you want just to know if some author already voted some resource and receive `true` or `false`:
110
+
111
+ ```ruby
112
+ author = Author.last
113
+ resource = Comment.last
114
+
115
+ author.voted? resource
116
+ ```
117
+
118
+ ### votes
119
+
120
+ You can retrieve all votes received by some resource:
121
+
122
+ ```ruby
123
+ resource = Article.last
124
+
125
+ resource.votes
126
+ ```
127
+
128
+ It will return a collection of `Voting::Vote` object.
129
+
130
+ ### voted
131
+
132
+ In the same way you can retrieve all votes that some author made:
133
+
134
+ ```ruby
135
+ author = Author.last
136
+
137
+ author.voted
138
+ ```
139
+
140
+ It will return a collection of `Voting::Vote` object.
141
+
142
+ ### order_by_voting
143
+
144
+ You can list resource ordered by voting data:
145
+
146
+ ```ruby
147
+ Comment.order_by_voting
148
+ ```
149
+
150
+ It will return a collection of resource ordered by `estimate desc` as default.
151
+ The order column and direction can be changed:
152
+
153
+ ```ruby
154
+ Comment.order_by_voting :negative, :asc
155
+ ```
156
+
157
+ It will return a collection of resource ordered by `Voting::Voting` table data.
158
+
159
+ ### Records
160
+
161
+ Maybe you want to recover all records, so you can add the suffix `_records` on relations:
162
+
163
+ ```ruby
164
+ author = Author.last
165
+ resource = Comment.last
166
+
167
+ author.vote resource, 1
168
+
169
+ author.voting_records
170
+
171
+ author.voted_records
172
+
173
+ comment.voting_records
174
+ ```
175
+
176
+ ### As
177
+
178
+ If you have a model that will only be able to vote but not to receive a vote, configure it as `author`.
179
+ An author model still can be voted, but won't generate a Voting record with all values as zero to warm up the cache.
180
+
181
+ ```ruby
182
+ voting as: :author
183
+ ```
184
+
185
+ ### Scoping
186
+
187
+ If you need to warm up a record with scope, you need to setup the `scoping` relation.
188
+
189
+ ```ruby
190
+ class Resource < ApplicationRecord
191
+ voting scoping: :categories
192
+ end
193
+ ```
194
+
195
+ Now, when a resource is created, the cache will be generated for each related `category` as `scopeable`.
196
+
197
+ # Toggle
198
+
199
+ The toggle functions works out of box, so if you vote up twice or vote twice down, the vote will be canceled.
200
+ When you do that, the vote record is **not destroyed** instead, it receives zero on `negative` and `positive` column.
201
+
202
+ # Alias
203
+
204
+ You can to use alias to directly call `vote` with positive or negative data.
205
+
206
+ ```ruby
207
+ author = Author.last
208
+ resource = Comment.last
209
+
210
+ author.up resource # +1
211
+ author.down resource # -1
212
+ ```
213
+
214
+ ### Options
215
+
216
+ `down`: makes a negative vote;
217
+
218
+ `up`: makes a positive vote;
219
+
220
+ ### References
221
+
222
+ - [Evan Miller](https://www.evanmiller.org/how-not-to-sort-by-average-rating.html)
223
+ - [Jonathan Landy](http://efavdb.com/ranking-revisited)
224
+ - [Wilson Score Interval](https://en.wikipedia.org/wiki/Binomial_proportion_confidence_interval#Wilson_score_interval)
225
+ - [How to Count Thumb-Ups and Thumb-Downs](http://www.dcs.bbk.ac.uk/~dell/publications/dellzhang_ictir2011.pdf)
226
+
227
+ ## Love it!
228
+
229
+ Via [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=X8HEP2878NDEG&item_name=voting) or [LiberPay](https://liberapay.com/wbotelhos). Thanks! (:
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voting
4
+ class InstallGenerator < Rails::Generators::Base
5
+ source_root File.expand_path('../templates', __FILE__)
6
+
7
+ desc 'creates Voting migration'
8
+
9
+ def create_migration
10
+ template 'db/migrate/create_voting_tables.rb', "db/migrate/#{timestamp}_create_voting_tables.rb"
11
+ end
12
+
13
+ private
14
+
15
+ def timestamp
16
+ Time.current.strftime '%Y%m%d%H%M%S'
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateVotingTables < ActiveRecord::Migration[5.0]
4
+ def change
5
+ create_table :voting_votes do |t|
6
+ t.integer :negative, default: 0, null: false
7
+ t.integer :positive, default: 0, null: false
8
+
9
+ t.references :author, index: true, null: false, polymorphic: true
10
+ t.references :resource, index: true, null: false, polymorphic: true
11
+ t.references :scopeable, index: true, null: true, polymorphic: true
12
+
13
+ t.timestamps null: false
14
+ end
15
+
16
+ add_index :voting_votes, %i[author_id author_type resource_id resource_type scopeable_id scopeable_type],
17
+ name: :index_voting_votes_on_author_and_resource_and_scopeable,
18
+ unique: true
19
+
20
+ create_table :voting_votings do |t|
21
+ t.decimal :estimate, default: 0, mull: false, precision: 25, scale: 20
22
+ t.integer :negative, default: 0, null: false
23
+ t.integer :positive, default: 0, null: false
24
+
25
+ t.references :resource, index: true, null: false, polymorphic: true
26
+ t.references :scopeable, index: true, null: true, polymorphic: true
27
+
28
+ t.timestamps null: false
29
+ end
30
+
31
+ add_index :voting_votings, %i[resource_id resource_type scopeable_id scopeable_type],
32
+ name: :index_voting_votings_on_resource_and_scopeable,
33
+ unique: true
34
+ end
35
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voting
4
+ end
5
+
6
+ require 'voting/models/voting/extension'
7
+ require 'voting/models/voting/vote'
8
+ require 'voting/models/voting/voting'
9
+
10
+ ActiveRecord::Base.include Voting::Extension
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voting
4
+ module Extension
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ def down(resource, author: self, scope: nil)
9
+ vote resource, -1, author: author, scope: scope
10
+ end
11
+
12
+ def up(resource, author: self, scope: nil)
13
+ vote resource, 1, author: author, scope: scope
14
+ end
15
+
16
+ def vote(resource, value, author: self, scope: nil)
17
+ Vote.create author: author, resource: resource, scopeable: scope, value: value
18
+ end
19
+
20
+ def vote_for(resource, scope: nil)
21
+ Vote.vote_for author: self, resource: resource, scopeable: scope
22
+ end
23
+
24
+ def voted?(resource, scope: nil)
25
+ !vote_for(resource, scope: scope).nil?
26
+ end
27
+
28
+ def votes(scope: nil)
29
+ votes_records.where scopeable: scope
30
+ end
31
+
32
+ def voted(scope: nil)
33
+ voted_records.where scopeable: scope
34
+ end
35
+
36
+ def voting(scope: nil)
37
+ voting_records.find_by scopeable: scope
38
+ end
39
+
40
+ def voting_warm_up(scoping: nil)
41
+ return Voting.find_or_create_by(resource: self) if scoping.blank?
42
+
43
+ [scoping].flatten.compact.map do |attribute|
44
+ next unless respond_to?(attribute)
45
+
46
+ [public_send(attribute)].flatten.compact.map do |object|
47
+ Voting.find_or_create_by! resource: self, scopeable: object
48
+ end
49
+ end.flatten.compact
50
+ end
51
+ end
52
+
53
+ module ClassMethods
54
+ def voting(as: nil, scoping: nil)
55
+ after_save -> { voting_warm_up scoping: scoping }, unless: -> { as == :author }
56
+
57
+ has_many :voting_records,
58
+ as: :resource,
59
+ class_name: '::Voting::Voting',
60
+ dependent: :destroy
61
+
62
+ has_many :votes_records,
63
+ as: :resource,
64
+ class_name: '::Voting::Vote',
65
+ dependent: :destroy
66
+
67
+ has_many :voted_records,
68
+ as: :author,
69
+ class_name: '::Voting::Vote',
70
+ dependent: :destroy
71
+
72
+ scope :order_by_voting, ->(column = :estimate, direction = :desc, scope: nil) {
73
+ scope_values = {
74
+ scopeable_id: scope&.id,
75
+ scopeable_type: scope&.class&.base_class&.name
76
+ }
77
+
78
+ includes(:voting_records)
79
+ .where(Voting.table_name => scope_values)
80
+ .order("#{Voting.table_name}.#{column} #{direction}")
81
+ }
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Voting
4
+ class Vote < ActiveRecord::Base
5
+ self.table_name = 'voting_votes'
6
+
7
+ after_save :update_voting
8
+
9
+ belongs_to :author, polymorphic: true
10
+ belongs_to :resource, polymorphic: true
11
+ belongs_to :scopeable, polymorphic: true
12
+
13
+ validates :author, :negative, :positive, :resource, presence: true
14
+ validates :negative, :positive, inclusion: { in: [0, 1] }
15
+
16
+ validates :author_id, uniqueness: {
17
+ case_sensitive: false,
18
+ scope: %i[author_type resource_id resource_type scopeable_id scopeable_type]
19
+ }
20
+
21
+ def self.create(author:, resource:, scopeable: nil, value:)
22
+ record = find_or_initialize_by(author: author, resource: resource, scopeable: scopeable)
23
+ attribute = value.positive? ? :positive : :negative
24
+ canceled = record.persisted? && value.abs == record[attribute]
25
+
26
+ record.negative = 0
27
+ record.positive = 0
28
+ record[attribute] = value.abs unless canceled
29
+
30
+ record.save!
31
+
32
+ record
33
+ end
34
+
35
+ def self.vote_for(author:, resource:, scopeable: nil)
36
+ find_by author: author, resource: resource, scopeable: scopeable
37
+ end
38
+
39
+ private
40
+
41
+ def update_voting
42
+ ::Voting::Voting.update_voting resource, scopeable
43
+ end
44
+ end
45
+ end