commit 598c58af7e43ab66053a067600ca0b3d176664de Author: Washington Botelho Date: Sun Oct 29 23:44:23 2017 -0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7e1052c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.byebug_history +*.gem diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..aa4331e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,63 @@ +require: rubocop-rspec + +inherit_from: .rubocop_todo.yml + +AllCops: + Exclude: + - generators/rating/templates/**/* + +Rails: + Enabled: true + +Lint/Debugger: + Enabled: false + +Metrics/AbcSize: + Enabled: false + +Metrics/ClassLength: + Enabled: false + +Metrics/CyclomaticComplexity: + Enabled: false + +Metrics/LineLength: + Enabled: false + +Metrics/MethodLength: + Enabled: false + +Metrics/PerceivedComplexity: + Enabled: false + +Rails/HasAndBelongsToMany: + Enabled: false + +Rails/HttpPositionalArguments: + Enabled: false + +Style/IfUnlessModifier: + MaxLineLength: 0 + +Style/PercentLiteralDelimiters: + PreferredDelimiters: + '%i': '[]' + '%I': '[]' + '%r': () + '%w': '[]' + '%W': '[]' + +Style/AlignParameters: + EnforcedStyle: with_fixed_indentation + +Style/Documentation: + Enabled: false + +Style/Encoding: + Enabled: false + +Style/SpaceBeforeComma: + Enabled: false + +Style/SpaceBeforeFirstArg: + Enabled: false diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 0000000..e69de29 diff --git a/.ruby-gemset b/.ruby-gemset new file mode 100644 index 0000000..2202a51 --- /dev/null +++ b/.ruby-gemset @@ -0,0 +1 @@ +rating diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..8e8299d --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.4.2 diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..f819a51 --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: ruby diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ae045c3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## v0.1.0 + +- First release. diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..7f4f5e9 --- /dev/null +++ b/Gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..075d65b --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,187 @@ +PATH + remote: . + specs: + rating (0.1.0) + rails (>= 4.2, < 6) + +GEM + remote: https://rubygems.org/ + specs: + actioncable (5.1.4) + actionpack (= 5.1.4) + nio4r (~> 2.0) + websocket-driver (~> 0.6.1) + actionmailer (5.1.4) + actionpack (= 5.1.4) + actionview (= 5.1.4) + activejob (= 5.1.4) + mail (~> 2.5, >= 2.5.4) + rails-dom-testing (~> 2.0) + actionpack (5.1.4) + actionview (= 5.1.4) + activesupport (= 5.1.4) + rack (~> 2.0) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.2) + actionview (5.1.4) + activesupport (= 5.1.4) + builder (~> 3.1) + erubi (~> 1.4) + rails-dom-testing (~> 2.0) + rails-html-sanitizer (~> 1.0, >= 1.0.3) + activejob (5.1.4) + activesupport (= 5.1.4) + globalid (>= 0.3.6) + activemodel (5.1.4) + activesupport (= 5.1.4) + activerecord (5.1.4) + activemodel (= 5.1.4) + activesupport (= 5.1.4) + arel (~> 8.0) + activesupport (5.1.4) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (~> 0.7) + minitest (~> 5.1) + tzinfo (~> 1.1) + arel (8.0.0) + ast (2.3.0) + builder (3.2.3) + byebug (9.1.0) + coderay (1.1.2) + concurrent-ruby (1.0.5) + crass (1.0.2) + database_cleaner (1.6.2) + diff-lcs (1.3) + erubi (1.7.0) + factory_bot (4.8.2) + activesupport (>= 3.0.0) + factory_bot_rails (4.8.2) + factory_bot (~> 4.8.2) + railties (>= 3.0.0) + globalid (0.4.1) + activesupport (>= 4.2.0) + i18n (0.9.0) + concurrent-ruby (~> 1.0) + loofah (2.1.1) + crass (~> 1.0.2) + nokogiri (>= 1.5.9) + mail (2.6.6) + mime-types (>= 1.16, < 4) + method_source (0.9.0) + mime-types (3.1) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0521) + mini_portile2 (2.3.0) + minitest (5.10.3) + nio4r (2.1.0) + nokogiri (1.8.1) + mini_portile2 (~> 2.3.0) + parallel (1.12.0) + parser (2.4.0.0) + ast (~> 2.2) + powerpack (0.1.1) + pry (0.11.2) + coderay (~> 1.1.0) + method_source (~> 0.9.0) + pry-byebug (3.5.0) + byebug (~> 9.1) + pry (~> 0.10) + rack (2.0.3) + rack-test (0.7.0) + rack (>= 1.0, < 3) + rails (5.1.4) + actioncable (= 5.1.4) + actionmailer (= 5.1.4) + actionpack (= 5.1.4) + actionview (= 5.1.4) + activejob (= 5.1.4) + activemodel (= 5.1.4) + activerecord (= 5.1.4) + activesupport (= 5.1.4) + bundler (>= 1.3.0) + railties (= 5.1.4) + sprockets-rails (>= 2.0.0) + rails-dom-testing (2.0.3) + activesupport (>= 4.2.0) + nokogiri (>= 1.6) + rails-html-sanitizer (1.0.3) + loofah (~> 2.0) + railties (5.1.4) + actionpack (= 5.1.4) + activesupport (= 5.1.4) + method_source + rake (>= 0.8.7) + thor (>= 0.18.1, < 2.0) + rainbow (2.2.2) + rake + rake (12.2.1) + rspec (3.7.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-core (3.7.0) + rspec-support (~> 3.7.0) + rspec-expectations (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-html-matchers (0.9.1) + nokogiri (~> 1) + rspec (>= 3.0.0.a, < 4) + rspec-mocks (3.7.0) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.7.0) + rspec-rails (3.7.1) + actionpack (>= 3.0) + activesupport (>= 3.0) + railties (>= 3.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-support (~> 3.7.0) + rspec-support (3.7.0) + rubocop (0.51.0) + parallel (~> 1.10) + parser (>= 2.3.3.1, < 3.0) + powerpack (~> 0.1) + rainbow (>= 2.2.2, < 3.0) + ruby-progressbar (~> 1.7) + unicode-display_width (~> 1.0, >= 1.0.1) + rubocop-rspec (1.19.0) + rubocop (>= 0.51.0) + ruby-progressbar (1.9.0) + shoulda-matchers (3.1.2) + activesupport (>= 4.0.0) + sprockets (3.7.1) + concurrent-ruby (~> 1.0) + rack (> 1, < 3) + sprockets-rails (3.2.1) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) + sqlite3 (1.3.13) + thor (0.20.0) + thread_safe (0.3.6) + tzinfo (1.2.4) + thread_safe (~> 0.1) + unicode-display_width (1.3.0) + websocket-driver (0.6.5) + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.2) + +PLATFORMS + ruby + +DEPENDENCIES + database_cleaner + factory_bot_rails + pry-byebug + rating! + rspec-html-matchers + rspec-rails + rubocop-rspec + shoulda-matchers + sqlite3 + +BUNDLED WITH + 1.15.4 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..694ff7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Washington Botelho + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a78fcf7 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# Rating + +[![Build Status](https://travis-ci.org/wbotelhos/rating.svg)](https://travis-ci.org/wbotelhos/rating) +[![Gem Version](https://badge.fury.io/rb/rating.svg)](https://badge.fury.io/rb/rating) + +A true Bayesian rating system with cache enabled. + +## JS Rating? + +This is **Raty**: https://github.com/wbotelhos/raty :star2: + +## Description + +Rating uses the know as "True Bayesian Estimate" inspired on [IMDb rating](http://www.imdb.com/help/show_leaf?votestopfaq) with the following formula: + +``` +(WR) = (v ÷ (v + m)) × R + (m ÷ (v + m)) × C +``` + +**IMDb Implementation:** + +`WR`: weighted rating + +`R`: average for the movie (mean) = (Rating) + +`v`: number of votes for the movie = (votes) + +`m`: minimum votes required to be listed in the Top 250 + +`C`: the mean vote across the whole report + +**Rating Implementation:** + +`WR`: weighted rating + +`R`: average for the resource (mean) = (Rating) + +`v`: number of votes for the movie = (votes) + +`m`: average of the number of votes + +`C`: the mean vote across the whole report + +## Install + +Add the following code on your `Gemfile` and run `bundle install`: + +```ruby +gem 'rating' +``` + +Run the following task to create a Rating migration: + +```bash +rails g rating:install +``` + +Then execute the migrations to create the to create tables `rating_rates` and `rating_ratings`: + +```bash +rake db:migrate +``` + +## Usage + +Just add the callback `rating` to your model: + +```ruby +class User < ApplicationRecord + rating +end +``` + +Now this model can vote or be voted. + +### rate + +You can vote on some resource: + +```ruby +author = User.last +resource = Article.last + +author.rate(resource, 3) +``` + +### rating + +A voted resource exposes a cached data about it state: + +```ruby +resource = Article.last + +resource.rating +``` + +It will return a `Rating` object that keeps: + +`average`: the normal mean of votes; + +`estimate`: the true Bayesian estimate mean value (you should use this over average); + +`sum`: the sum of votes for this resource; + +`total`: the total of votes for this resource. + +### rate_for + +You can retrieve the rate of some author gave to some resource: + +```ruby +author = User.last +resource = Article.last + +author.rate_for resource +``` + +It will return a `Rate` object that keeps: + +`author`: the author of vote; + +`resource`: the resource that received the vote; + +`value`: the value of the vote. + +### rated? + +Maybe you want just to know if some author already rated some resource and receive `true` or `false`: + +```ruby +author = User.last +resource = Article.last + +author.rated? resource +``` + +### rates + +You can retrieve all rates made by some author: + +```ruby +author = User.last + +author.rates +``` + +It will return a collection of `Rate` object. + +### rated + +In the same way you can retrieve all rates that some author received: + +```ruby +author = User.last + +author.rated +``` + +It will return a collection of `Rate` object. + +### order_by_rating + +You can list resource ordered by rating data: + +```ruby +Article.order_by_rating +``` + +It will return a collection of resource ordered by `estimate desc` as default. +The order column and direction can be changed: + +```ruby +Article.order_by_rating :average, :asc +``` + +It will return a collection of resource ordered by `Rating` table data. + +## Love it! + +Via [PayPal](https://www.paypal.com/cgi-bin/webscr?cmd=_donations&business=X8HEP2878NDEG&item_name=rating) or [Gratipay](https://gratipay.com/rating). Thanks! (: diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..dc8c23d --- /dev/null +++ b/Rakefile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rspec/core/rake_task' + +RSpec::Core::RakeTask.new + +task default: :spec diff --git a/lib/generators/rating/install_generator.rb b/lib/generators/rating/install_generator.rb new file mode 100644 index 0000000..5332239 --- /dev/null +++ b/lib/generators/rating/install_generator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Rating + class InstallGenerator < Rails::Generators::Base + source_root File.expand_path('../templates', __FILE__) + + desc 'configure Rating' + + def create_migration + version = Time.current.strftime('%Y%m%d%H%M%S') + + template 'db/migrate/create_rating_tables.rb', "db/migrate/#{version}_create_rating_tables.rb" + end + end +end diff --git a/lib/generators/rating/templates/db/migrate/create_rating_tables.rb b/lib/generators/rating/templates/db/migrate/create_rating_tables.rb new file mode 100644 index 0000000..48b3dec --- /dev/null +++ b/lib/generators/rating/templates/db/migrate/create_rating_tables.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreateRatingTables < ActiveRecord::Migration[5.0] + def change + create_table :rating_rates do |t| + t.decimal :value, default: 0, precision: 17, scale: 14 + + t.references :author , index: true, null: false, polymorphic: true + t.references :resource, index: true, null: false, polymorphic: true + + t.timestamps null: false + end + + add_index :rating_rates, %i[author_id author_type resource_id resource_type], + name: :index_rating_rates_on_author_and_resource, + unique: true + + create_table :rating_ratings do |t| + t.decimal :average , default: 0, mull: false, precision: 17, scale: 14 + t.decimal :estimate, default: 0, mull: false, precision: 17, scale: 14 + t.integer :sum , default: 0, mull: false + t.integer :total , default: 0, mull: false + + t.references :resource, index: true, null: false, polymorphic: true + + t.timestamps null: false + end + + add_index :rating_ratings, %i[resource_id resource_type], unique: true + end +end diff --git a/lib/rating.rb b/lib/rating.rb new file mode 100644 index 0000000..68a56da --- /dev/null +++ b/lib/rating.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Rating +end + +require 'rating/models/rating/extension' +require 'rating/models/rating/rate' +require 'rating/models/rating/rating' + +ActiveRecord::Base.include Rating::Extension diff --git a/lib/rating/models/rating/extension.rb b/lib/rating/models/rating/extension.rb new file mode 100644 index 0000000..07a2e85 --- /dev/null +++ b/lib/rating/models/rating/extension.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Rating + module Extension + extend ActiveSupport::Concern + + included do + def rate(resource, value, author: self) + Rate.create author: author, resource: resource, value: value + end + + def rate_for(resource) + Rate.rate_for author: self, resource: resource + end + + def rated?(resource) + !rate_for(resource).nil? + end + end + + module ClassMethods + def rating + after_create { Rating.find_or_create_by resource: self } + + has_one :rating, + as: :resource, + class_name: '::Rating::Rating', + dependent: :destroy + + has_many :rates, + as: :resource, + class_name: '::Rating::Rate', + dependent: :destroy + + has_many :rated, + as: :author, + class_name: '::Rating::Rate', + dependent: :destroy + + scope :order_by_rating, ->(column = :estimate, direction = :desc) { + includes(:rating).order("#{Rating.table_name}.#{column} #{direction}") + } + end + end + end +end diff --git a/lib/rating/models/rating/rate.rb b/lib/rating/models/rating/rate.rb new file mode 100644 index 0000000..7ca79ab --- /dev/null +++ b/lib/rating/models/rating/rate.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Rating + class Rate < ActiveRecord::Base + self.table_name = 'rating_rates' + + after_save :update_rating + + belongs_to :author , polymorphic: true + belongs_to :resource, polymorphic: true + + validates :author, :resource, :value, presence: true + validates :value, numericality: { greater_than_or_equal_to: 1, less_than_or_equal_to: 100 } + + validates :author_id, uniqueness: { + case_sensitive: false, + scope: %i[author_type resource_id resource_type] + } + + def self.create(author:, resource:, value:) + record = find_or_initialize_by(author: author, resource: resource) + + return record if record.persisted? && value == record.value + + record.value = value + record.save + + record + end + + def self.rate_for(author:, resource:) + find_by author: author, resource: resource + end + + private + + def update_rating + ::Rating::Rating.update_rating resource + end + end +end diff --git a/lib/rating/models/rating/rating.rb b/lib/rating/models/rating/rating.rb new file mode 100644 index 0000000..b07a1dc --- /dev/null +++ b/lib/rating/models/rating/rating.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Rating + class Rating < ActiveRecord::Base + self.table_name = 'rating_ratings' + + belongs_to :resource, polymorphic: true + + validates :average, :estimate, :resource, :sum, :total, presence: true + validates :average, :estimate, :sum, :total, numericality: true + + class << self + def averager_data(resource) + total_count = how_many_resource_received_votes_sql? + distinct_count = how_many_resource_received_votes_sql?(distinct: true) + + sql = %( + SELECT + (#{total_count} / CAST(#{distinct_count} AS float)) count_avg, + COALESCE(AVG(value), 0) rating_avg + FROM #{rate_table_name} + WHERE resource_type = :resource_type + ).squish + + execute_sql [sql, resource_type: resource.class.base_class.name] + end + + def data(resource) + averager = averager_data(resource) + values = values_data(resource) + + { + average: values.rating_avg, + estimate: estimate(averager, values), + sum: values.rating_sum, + total: values.rating_count + } + end + + def values_data(resource) + sql = %( + SELECT + COALESCE(AVG(value), 0) rating_avg, + COALESCE(SUM(value), 0) rating_sum, + COUNT(1) rating_count + FROM #{rate_table_name} + WHERE resource_type = ? and resource_id = ? + ).squish + + execute_sql [sql, resource.class.base_class.name, resource.id] + end + + def update_rating(resource) + record = find_or_initialize_by(resource: resource) + result = data(resource) + + record.average = result[:average] + record.sum = result[:sum] + record.total = result[:total] + record.estimate = result[:estimate] + + record.save + end + + private + + def estimate(averager, values) + resource_type_rating_avg = averager.rating_avg + count_avg = averager.count_avg + resource_rating_avg = values.rating_avg + resource_rating_count = values.rating_count.to_f + + (resource_rating_count / (resource_rating_count + count_avg)) * resource_rating_avg + + (count_avg / (resource_rating_count + count_avg)) * resource_type_rating_avg + end + + def execute_sql(sql) + Rate.find_by_sql(sql).first + end + + def how_many_resource_received_votes_sql?(distinct: false) + count = distinct ? 'COUNT(DISTINCT resource_id)' : 'COUNT(1)' + + %(( + SELECT #{count} + FROM #{rate_table_name} + WHERE resource_type = :resource_type + )) + end + + def rate_table_name + @rate_table_name ||= Rate.table_name + end + end + end +end diff --git a/lib/rating/version.rb b/lib/rating/version.rb new file mode 100644 index 0000000..8b9cb0c --- /dev/null +++ b/lib/rating/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Rating + VERSION = '0.1.0' +end diff --git a/rating.gemspec b/rating.gemspec new file mode 100644 index 0000000..eb2a622 --- /dev/null +++ b/rating.gemspec @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +lib = File.expand_path('../lib', __FILE__) + +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) + +require 'rating/version' + +Gem::Specification.new do |spec| + spec.author = 'Washington Botelho' + spec.description = 'A true Bayesian rating system with cache enabled.' + spec.email = 'wbotelhos@gmail.com' + spec.files = Dir['lib/**/*'] + %w[CHANGELOG.md LICENSE README.md] + spec.homepage = 'https://github.com/wbotelhos/rating' + spec.license = 'MIT' + spec.name = 'rating' + spec.platform = Gem::Platform::RUBY + spec.summary = 'A true Bayesian rating system with cache enabled.' + spec.test_files = Dir['spec/**/*'] + spec.version = Rating::VERSION + + spec.add_dependency 'rails', '>= 4.2', '< 6' + + spec.add_development_dependency 'database_cleaner' + spec.add_development_dependency 'factory_bot_rails' + spec.add_development_dependency 'pry-byebug' + spec.add_development_dependency 'rspec-html-matchers' + spec.add_development_dependency 'rspec-rails' + spec.add_development_dependency 'rubocop-rspec' + spec.add_development_dependency 'shoulda-matchers' + spec.add_development_dependency 'sqlite3' +end diff --git a/spec/factories/article.rb b/spec/factories/article.rb new file mode 100644 index 0000000..7c18c71 --- /dev/null +++ b/spec/factories/article.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :article do + sequence(:name) { |i| "Name #{i}" } + end +end diff --git a/spec/factories/rating/rate.rb b/spec/factories/rating/rate.rb new file mode 100644 index 0000000..5b85595 --- /dev/null +++ b/spec/factories/rating/rate.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :rating_rate, class: Rating::Rate do + value 100 + + author { create :user } + resource { create :article } + end +end diff --git a/spec/factories/rating/rating.rb b/spec/factories/rating/rating.rb new file mode 100644 index 0000000..920540d --- /dev/null +++ b/spec/factories/rating/rating.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :rating_rating, class: Rating::Rating do + average 100 + estimate 100 + sum 100 + total 1 + + resource { create :article } + end +end diff --git a/spec/factories/user.rb b/spec/factories/user.rb new file mode 100644 index 0000000..d6e337f --- /dev/null +++ b/spec/factories/user.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user do + sequence(:name) { |i| "Name #{i}" } + end +end diff --git a/spec/models/extension/after_create_spec.rb b/spec/models/extension/after_create_spec.rb new file mode 100644 index 0000000..6c68ce0 --- /dev/null +++ b/spec/models/extension/after_create_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':after_create' do + context 'when creates object' do + let!(:user) { create :user } + + it 'creates a record with zero values just to be easy to make the count' do + rating = Rating::Rating.find_by(resource: user) + + expect(rating.average).to eq 0 + expect(rating.estimate).to eq 0 + expect(rating.resource).to eq user + expect(rating.sum).to eq 0 + expect(rating.total).to eq 0 + end + end +end diff --git a/spec/models/extension/order_by_rating_spec.rb b/spec/models/extension/order_by_rating_spec.rb new file mode 100644 index 0000000..7f6b450 --- /dev/null +++ b/spec/models/extension/order_by_rating_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':order_by_rating' do + let!(:user_1) { create :user } + let!(:user_2) { create :user } + + let!(:article_1) { create :article } + let!(:article_2) { create :article } + let!(:article_3) { create :article } + + before do + create :rating_rate, author: user_1, resource: article_1, value: 100 + create :rating_rate, author: user_1, resource: article_2, value: 11 + create :rating_rate, author: user_1, resource: article_3, value: 10 + create :rating_rate, author: user_2, resource: article_1, value: 1 + end + + context 'with default filters' do + it 'sorts by :estimate :desc' do + expect(Article.order_by_rating).to eq [ + article_1, + article_2, + article_3 + ] + end + end + + context 'filtering by :average' do + context 'as asc' do + it 'works' do + expect(Article.order_by_rating(:average, :asc)).to eq [ + article_3, + article_2, + article_1 + ] + end + end + + context 'as desc' do + it 'works' do + expect(Article.order_by_rating(:average, :desc)).to eq [ + article_1, + article_2, + article_3 + ] + end + end + end + + context 'filtering by :estimate' do + context 'as asc' do + it 'works' do + expect(Article.order_by_rating(:estimate, :asc)).to eq [ + article_3, + article_2, + article_1 + ] + end + end + + context 'as desc' do + it 'works' do + expect(Article.order_by_rating(:estimate, :desc)).to eq [ + article_1, + article_2, + article_3 + ] + end + end + end + + context 'filtering by :sum' do + context 'as asc' do + it 'works' do + expect(Article.order_by_rating(:sum, :asc)).to eq [ + article_3, + article_2, + article_1 + ] + end + end + + context 'as desc' do + it 'works' do + expect(Article.order_by_rating(:sum, :desc)).to eq [ + article_1, + article_2, + article_3 + ] + end + end + + context 'filtering by :total' do + context 'as asc' do + it 'works' do + result = Article.order_by_rating(:total, :asc) + + expect(result[0..1]).to match_array [article_2, article_3] + expect(result.last).to eq article_1 + end + end + + context 'as desc' do + it 'works' do + result = Article.order_by_rating(:total, :desc) + + expect(result.first).to eq article_1 + expect(result[1..2]).to match_array [article_2, article_3] + end + end + end + end + + context 'with other resource' do + it 'works' do + expect(User.order_by_rating(:total, :desc)).to match_array [user_1, user_2] + end + end +end diff --git a/spec/models/extension/rate_for_spec.rb b/spec/models/extension/rate_for_spec.rb new file mode 100644 index 0000000..daa4bd0 --- /dev/null +++ b/spec/models/extension/rate_for_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':rate_for' do + let!(:user) { create :user } + let!(:article) { create :article } + + it 'delegates to rate object' do + expect(Rating::Rate).to receive(:rate_for).with author: user, resource: article + + user.rate_for article + end +end diff --git a/spec/models/extension/rate_spec.rb b/spec/models/extension/rate_spec.rb new file mode 100644 index 0000000..ea4b9a8 --- /dev/null +++ b/spec/models/extension/rate_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':rate' do + let!(:user) { create :user } + let!(:article) { create :article } + + it 'delegates to rate object' do + expect(Rating::Rate).to receive(:create).with author: user, resource: article, value: 3 + + user.rate article, 3 + end +end diff --git a/spec/models/extension/rated_question_spec.rb b/spec/models/extension/rated_question_spec.rb new file mode 100644 index 0000000..29f84f2 --- /dev/null +++ b/spec/models/extension/rated_question_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':rated?' do + let!(:user) { create :user } + let!(:article) { create :article } + + context 'when has no rate for the given resource' do + before { allow(user).to receive(:rate_for).with(article) { nil } } + + specify { expect(user.rated?(article)).to eq false } + end + + context 'when has rate for the given resource' do + before { allow(user).to receive(:rate_for).with(article) { double } } + + specify { expect(user.rated?(article)).to eq true } + end +end diff --git a/spec/models/extension/rated_spec.rb b/spec/models/extension/rated_spec.rb new file mode 100644 index 0000000..b20e91b --- /dev/null +++ b/spec/models/extension/rated_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':rated' do + let!(:user) { create :user } + let!(:article) { create :article } + + before { user.rate article, 3 } + + it 'returns rates made by the caller' do + expect(user.rated).to eq [Rating::Rate.find_by(resource: article)] + end + + context 'when destroy author' do + before do + expect(Rating::Rate.where(author: user).count).to eq 1 + + user.destroy! + end + + it 'destroys rates of this author' do + expect(Rating::Rate.where(author: user).count).to eq 0 + end + end + + context 'when destroy resource rated by author' do + before do + expect(Rating::Rate.where(resource: article).count).to eq 1 + + article.destroy! + end + + it 'destroys rates for that resource' do + expect(Rating::Rate.where(resource: article).count).to eq 0 + end + end +end diff --git a/spec/models/extension/rates_spec.rb b/spec/models/extension/rates_spec.rb new file mode 100644 index 0000000..1f3167a --- /dev/null +++ b/spec/models/extension/rates_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':rates' do + let!(:user) { create :user } + let!(:article) { create :article } + + before { user.rate article, 3 } + + it 'returns rates record' do + expect(article.rates).to eq [Rating::Rate.last] + end + + context 'when destroy author' do + before do + expect(Rating::Rate.where(resource: article).count).to eq 1 + + user.destroy! + end + + it 'destroys rates of that resource' do + expect(Rating::Rate.where(resource: article).count).to eq 0 + end + end + + context 'when destroy resource' do + before do + expect(Rating::Rate.where(resource: article).count).to eq 1 + + article.destroy! + end + + it 'destroys rates of that resource' do + expect(Rating::Rate.where(resource: article).count).to eq 0 + end + end +end diff --git a/spec/models/extension/rating_spec.rb b/spec/models/extension/rating_spec.rb new file mode 100644 index 0000000..574a257 --- /dev/null +++ b/spec/models/extension/rating_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Extension, ':rating' do + let!(:user_1) { create :user } + let!(:article) { create :article } + + before { user_1.rate article, 1 } + + it 'returns rating record' do + expect(article.rating).to eq Rating::Rating.last + end + + context 'when destroy author' do + let!(:user_2) { create :user } + + before { user_2.rate article, 2 } + + it 'does not destroy resource rating' do + expect(Rating::Rating.where(resource: article).count).to eq 1 + + user_1.destroy! + + expect(Rating::Rating.where(resource: article).count).to eq 1 + end + end + + context 'when destroy resource' do + it 'destroys resource rating too' do + expect(Rating::Rating.where(resource: article).count).to eq 1 + + article.destroy! + + expect(Rating::Rating.where(resource: article).count).to eq 0 + end + end +end diff --git a/spec/models/rate/create_spec.rb b/spec/models/rate/create_spec.rb new file mode 100644 index 0000000..ae1f7fc --- /dev/null +++ b/spec/models/rate/create_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rate, ':create' do + let!(:user) { create :user } + let!(:article) { create :article } + + before { create :rating_rate, author: user, resource: article, value: 3 } + + context 'when rate does not exist yet' do + it 'creates a rate entry' do + rate = described_class.last + + expect(rate.author).to eq user + expect(rate.resource).to eq article + expect(rate.value).to eq 3 + end + + it 'creates a rating entry' do + rating = Rating::Rating.last + + expect(rating.average).to eq 3 + expect(rating.estimate).to eq 3 + expect(rating.resource).to eq article + expect(rating.sum).to eq 3 + expect(rating.total).to eq 1 + end + end + + context 'when rate already exists' do + let!(:user_2) { create :user } + + before { create :rating_rate, author: user_2, resource: article, value: 4 } + + it 'creates one more rate entry' do + rates = described_class.where(author: [user, user_2]).order('created_at asc') + + expect(rates.size).to eq 2 + + rate = rates[0] + + expect(rate.author).to eq user + expect(rate.resource).to eq article + expect(rate.value).to eq 3 + + rate = rates[1] + + expect(rate.author).to eq user_2 + expect(rate.resource).to eq article + expect(rate.value).to eq 4 + end + + it 'updates the unique rating entry' do + rating = Rating::Rating.find_by(resource: article) + + expect(rating.average).to eq 3.5 + expect(rating.estimate).to eq 3.5 + expect(rating.resource).to eq article + expect(rating.sum).to eq 7 + expect(rating.total).to eq 2 + end + end +end diff --git a/spec/models/rate/rate_for_spec.rb b/spec/models/rate/rate_for_spec.rb new file mode 100644 index 0000000..1527421 --- /dev/null +++ b/spec/models/rate/rate_for_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rate, ':rate_for' do + let!(:user) { create :user } + let!(:article) { create :article } + + context 'when rate does not exist' do + specify { expect(described_class.rate_for(author: user, resource: article)).to eq nil } + end + + context 'when rate does not exist' do + before { described_class.create author: user, resource: article, value: 3 } + + it 'returns the record' do + expect(described_class.rate_for(author: user, resource: article)).to eq described_class.last + end + end +end diff --git a/spec/models/rate_spec.rb b/spec/models/rate_spec.rb new file mode 100644 index 0000000..c343615 --- /dev/null +++ b/spec/models/rate_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rate do + let!(:object) { build :rating_rate } + + it { expect(object).to be_valid } + + it { is_expected.to belong_to :author } + it { is_expected.to belong_to :resource } + + it { is_expected.to validate_presence_of :author } + it { is_expected.to validate_presence_of :resource } + it { is_expected.to validate_presence_of :value } + + specify do + is_expected.to validate_numericality_of(:value).is_less_than_or_equal_to(100).is_less_than_or_equal_to 100 + end + + specify do + expect(object).to validate_uniqueness_of(:author_id) + .scoped_to(%i[author_type resource_id resource_type]) + .case_insensitive + end +end diff --git a/spec/models/rating/averager_data_spec.rb b/spec/models/rating/averager_data_spec.rb new file mode 100644 index 0000000..7257beb --- /dev/null +++ b/spec/models/rating/averager_data_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rating, ':averager_data' do + subject { described_class.averager_data article_1 } + + let!(:user_1) { create :user } + let!(:user_2) { create :user } + + let!(:article_1) { create :article } + let!(:article_2) { create :article } + let!(:article_3) { create :article } + + before do + create :rating_rate, author: user_1, resource: article_1, value: 100 + create :rating_rate, author: user_1, resource: article_2, value: 11 + create :rating_rate, author: user_1, resource: article_3, value: 10 + create :rating_rate, author: user_2, resource: article_1, value: 1 + end + + it 'returns the values average of given resource type' do + expect(subject.as_json['rating_avg']).to eq 30.5 + end + + it 'returns the average of number of records for the given resource type' do + expect(subject.as_json['count_avg']).to eq 1.3333333333333333 + end +end diff --git a/spec/models/rating/data_spec.rb b/spec/models/rating/data_spec.rb new file mode 100644 index 0000000..cbbedad --- /dev/null +++ b/spec/models/rating/data_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rating, ':data' do + subject { described_class.data article_1 } + + let!(:user_1) { create :user } + let!(:user_2) { create :user } + + let!(:article_1) { create :article } + let!(:article_2) { create :article } + let!(:article_3) { create :article } + + before do + create :rating_rate, author: user_1, resource: article_1, value: 100 + create :rating_rate, author: user_1, resource: article_2, value: 11 + create :rating_rate, author: user_1, resource: article_3, value: 10 + create :rating_rate, author: user_2, resource: article_1, value: 1 + end + + it 'returns the average of value for a resource' do + expect(subject[:average]).to eq 50.5 + end + + it 'returns the sum of values for a resource' do + expect(subject[:sum]).to eq 101 + end + + it 'returns the count of votes for a resource' do + expect(subject[:total]).to eq 2 + end + + it 'returns the estimate for a resource' do + expect(subject[:estimate]).to eq 42.50000000000001 + end +end diff --git a/spec/models/rating/update_rating_spec.rb b/spec/models/rating/update_rating_spec.rb new file mode 100644 index 0000000..416ec35 --- /dev/null +++ b/spec/models/rating/update_rating_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rating, ':update_rating' do + let!(:user_1) { create :user } + let!(:user_2) { create :user } + + let!(:article_1) { create :article } + let!(:article_2) { create :article } + let!(:article_3) { create :article } + + before do + create :rating_rate, author: user_1, resource: article_1, value: 100 + create :rating_rate, author: user_1, resource: article_2, value: 11 + create :rating_rate, author: user_1, resource: article_3, value: 10 + create :rating_rate, author: user_2, resource: article_1, value: 1 + end + + it 'updates the rating data of the given resource' do + record = described_class.find_by(resource: article_1) + + expect(record.average).to eq 50.50000000000001 + expect(record.estimate).to eq 42.50000000000001 + expect(record.sum).to eq 101 + expect(record.total).to eq 2 + end +end diff --git a/spec/models/rating/values_data_spec.rb b/spec/models/rating/values_data_spec.rb new file mode 100644 index 0000000..ff42692 --- /dev/null +++ b/spec/models/rating/values_data_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rating, ':values_data' do + subject { described_class.values_data article_1 } + + let!(:user_1) { create :user } + let!(:user_2) { create :user } + + let!(:article_1) { create :article } + let!(:article_2) { create :article } + let!(:article_3) { create :article } + + before do + create :rating_rate, author: user_1, resource: article_1, value: 100 + create :rating_rate, author: user_1, resource: article_2, value: 11 + create :rating_rate, author: user_1, resource: article_3, value: 10 + create :rating_rate, author: user_2, resource: article_1, value: 4 + end + + it 'returns the average of value for a resource' do + expect(subject.as_json['rating_avg']).to eq 52.0 + end + + it 'returns the sum of values for a resource' do + expect(subject.as_json['rating_sum']).to eq 104 + end + + it 'returns the count of votes for a resource' do + expect(subject.as_json['rating_count']).to eq 2 + end +end diff --git a/spec/models/rating_spec.rb b/spec/models/rating_spec.rb new file mode 100644 index 0000000..5fd3bf9 --- /dev/null +++ b/spec/models/rating_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Rating::Rating do + let!(:object) { build :rating_rating } + + it { expect(object).to be_valid } + + it { is_expected.to belong_to :resource } + + it { is_expected.to validate_presence_of :average } + it { is_expected.to validate_presence_of :estimate } + it { is_expected.to validate_presence_of :resource } + it { is_expected.to validate_presence_of :sum } + it { is_expected.to validate_presence_of :total } +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..c3e5af4 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +ENV['RAILS_ENV'] ||= 'test' + +require 'active_record/railtie' +require 'pry-byebug' +require 'rating' + +ActiveRecord::Base.establish_connection adapter: :sqlite3, database: ':memory:' + +Dir[File.expand_path('support/**/*.rb', __dir__)].each { |file| require file } diff --git a/spec/support/common.rb b/spec/support/common.rb new file mode 100644 index 0000000..d601c1b --- /dev/null +++ b/spec/support/common.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rspec/rails' + +RSpec.configure do |config| + config.filter_run_when_matching :focus + + config.disable_monkey_patching! + config.infer_spec_type_from_file_location! + + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.infer_base_class_for_anonymous_controllers = false + config.order = :random + config.profile_examples = true +end diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb new file mode 100644 index 0000000..43fbd7c --- /dev/null +++ b/spec/support/database_cleaner.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'database_cleaner' + +RSpec.configure do |config| + config.before :suite do + DatabaseCleaner.strategy = :transaction + + DatabaseCleaner.clean_with :truncation + end + + config.before { DatabaseCleaner.start } + + config.around do |spec| + DatabaseCleaner.cleaning do + spec.run + end + end +end diff --git a/spec/support/db/migrate/create_articles_table.rb b/spec/support/db/migrate/create_articles_table.rb new file mode 100644 index 0000000..96fa9ae --- /dev/null +++ b/spec/support/db/migrate/create_articles_table.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class CreateArticlesTable < ActiveRecord::Migration[5.0] + def change + create_table :articles do |t| + t.string :name, null: false + end + end +end diff --git a/spec/support/db/migrate/create_users_table.rb b/spec/support/db/migrate/create_users_table.rb new file mode 100644 index 0000000..f3e4835 --- /dev/null +++ b/spec/support/db/migrate/create_users_table.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class CreateUsersTable < ActiveRecord::Migration[5.0] + def change + create_table :users do |t| + t.string :name, null: false + end + end +end diff --git a/spec/support/factory_bot.rb b/spec/support/factory_bot.rb new file mode 100644 index 0000000..9a650f3 --- /dev/null +++ b/spec/support/factory_bot.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'factory_bot' + +Dir[File.expand_path('../factories/**/*.rb', __dir__)].each { |file| require file } + +RSpec.configure do |config| + config.include FactoryBot::Syntax::Methods +end diff --git a/spec/support/html_matchers.rb b/spec/support/html_matchers.rb new file mode 100644 index 0000000..dfab9c8 --- /dev/null +++ b/spec/support/html_matchers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rspec-html-matchers' + +RSpec.configure do |config| + config.include RSpecHtmlMatchers +end diff --git a/spec/support/migrate.rb b/spec/support/migrate.rb new file mode 100644 index 0000000..f768526 --- /dev/null +++ b/spec/support/migrate.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require File.expand_path('../../lib/generators/rating/templates/db/migrate/create_rating_tables.rb', __dir__) + +CreateRatingTables.new.change +CreateUsersTable.new.change +CreateArticlesTable.new.change diff --git a/spec/support/models/article.rb b/spec/support/models/article.rb new file mode 100644 index 0000000..85e8d8c --- /dev/null +++ b/spec/support/models/article.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Article < ::ActiveRecord::Base + rating +end diff --git a/spec/support/models/user.rb b/spec/support/models/user.rb new file mode 100644 index 0000000..a92ffd2 --- /dev/null +++ b/spec/support/models/user.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class User < ::ActiveRecord::Base + rating +end diff --git a/spec/support/shoulda.rb b/spec/support/shoulda.rb new file mode 100644 index 0000000..18b550e --- /dev/null +++ b/spec/support/shoulda.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require 'shoulda-matchers' + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.library :rails + with.test_framework :rspec + end +end