├── .rspec ├── .gitignore ├── spec ├── support │ └── rails_app │ │ ├── app │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── application_record.rb │ │ │ ├── user.rb │ │ │ ├── composer.rb │ │ │ └── favorite.rb │ │ └── assets │ │ │ └── config │ │ │ └── manifest.js │ │ ├── .gitignore │ │ ├── db │ │ ├── test.sqlite3-shm │ │ ├── test.sqlite3-wal │ │ ├── migrate │ │ │ ├── 20190504083814_create_users.rb │ │ │ ├── 20190504083836_create_composers.rb │ │ │ └── 20190504084849_acts_as_favoritor_migration.rb │ │ └── schema.rb │ │ ├── bin │ │ ├── bundle │ │ ├── rake │ │ └── rails │ │ ├── config.ru │ │ ├── config │ │ ├── routes.rb │ │ ├── boot.rb │ │ ├── environment.rb │ │ ├── initializers │ │ │ └── acts_as_favoritor.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ └── environments │ │ │ ├── test.rb │ │ │ └── development.rb │ │ ├── Rakefile │ │ ├── Gemfile │ │ └── Gemfile.lock ├── factories │ ├── composers.rb │ └── users.rb ├── rails_helper.rb ├── lib │ └── acts_as_favoritor │ │ ├── configuration_spec.rb │ │ ├── favoritor_lib_spec.rb │ │ ├── favorite_scopes_spec.rb │ │ ├── favoritable_spec.rb │ │ └── favoritor_spec.rb ├── integration │ ├── acts_as_favoritable_spec.rb │ └── acts_as_favoritor_spec.rb └── spec_helper.rb ├── lib ├── acts_as_favoritor │ ├── version.rb │ ├── railtie.rb │ ├── favoritor_lib.rb │ ├── configuration.rb │ ├── favorite_scopes.rb │ ├── favoritable.rb │ └── favoritor.rb ├── generators │ ├── templates │ │ ├── model.rb │ │ ├── initializer.rb │ │ └── migration.rb.erb │ └── acts_as_favoritor_generator.rb └── acts_as_favoritor.rb ├── .github ├── pull_request_template.md ├── ISSUE_TEMPLATE │ ├── documentation.md │ ├── bug_report.md │ └── feature_request.md ├── workflows │ ├── release.yml │ ├── ci.yml │ └── codeql-analysis.yml └── dependabot.yml ├── CHANGELOG.md ├── Gemfile ├── .rubocop.yml ├── SECURITY.md ├── LICENSE ├── acts_as_favoritor.gemspec ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile.lock └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.gem 2 | 3 | /**/*.log 4 | -------------------------------------------------------------------------------- /spec/support/rails_app/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/rails_app/.gitignore: -------------------------------------------------------------------------------- 1 | log 2 | tmp 3 | 4 | *.sqlite3 5 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsFavoritor 4 | VERSION = '6.0.2' 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/rails_app/db/test.sqlite3-shm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonhue/acts_as_favoritor/HEAD/spec/support/rails_app/db/test.sqlite3-shm -------------------------------------------------------------------------------- /spec/support/rails_app/db/test.sqlite3-wal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonhue/acts_as_favoritor/HEAD/spec/support/rails_app/db/test.sqlite3-wal -------------------------------------------------------------------------------- /spec/support/rails_app/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../javascripts .js 3 | //= link_directory ../stylesheets .css 4 | -------------------------------------------------------------------------------- /spec/support/rails_app/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | Briefly describe your changes and - if applicable - why you made those changes. 2 | 3 | Don't forget to reference the issue this pull request addresses. 4 | -------------------------------------------------------------------------------- /spec/support/rails_app/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 5 | load Gem.bin_path('bundler', 'bundle') 6 | -------------------------------------------------------------------------------- /spec/support/rails_app/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | acts_as_favoritor 5 | acts_as_favoritable 6 | 7 | validates :name, presence: true 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/rails_app/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative 'config/environment' 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /spec/support/rails_app/app/models/composer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Composer < ApplicationRecord 4 | acts_as_favoritor 5 | acts_as_favoritable 6 | 7 | validates :name, presence: true 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/rails_app/db/migrate/20190504083814_create_users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateUsers < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :users do |t| 6 | t.string :name 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/rails_app/db/migrate/20190504083836_create_composers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CreateComposers < ActiveRecord::Migration[5.2] 4 | def change 5 | create_table :composers do |t| 6 | t.string :name 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 4 | 5 | require 'bundler/setup' # Set up gems listed in the Gemfile. 6 | # require 'bootsnap/setup' # Speed up boot time by caching expensive operations. 7 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative 'application' 5 | 6 | require_relative '../../../../lib/acts_as_favoritor' 7 | 8 | # Initialize the Rails application. 9 | Rails.application.initialize! 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation 3 | about: Make changes to the documentation 4 | title: '' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | Describe what section of the documentation your changes will affect and how you want to change or add to this section. 11 | -------------------------------------------------------------------------------- /spec/factories/composers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :beethoven, class: 'Composer' do |composer| 5 | composer.name { 'Beethoven' } 6 | end 7 | 8 | factory :rossini, class: 'Composer' do |composer| 9 | composer.name { 'Rossini' } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/rails_app/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | begin 5 | load File.expand_path('spring', __dir__) 6 | rescue LoadError => e 7 | raise unless e.message.include?('spring') 8 | end 9 | require_relative '../config/boot' 10 | require 'rake' 11 | Rake.application.run 12 | -------------------------------------------------------------------------------- /spec/support/rails_app/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be 5 | # available to Rake. 6 | 7 | require_relative 'config/application' 8 | 9 | Rails.application.load_tasks 10 | -------------------------------------------------------------------------------- /lib/generators/templates/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Favorite < ApplicationRecord 4 | extend ActsAsFavoritor::FavoriteScopes 5 | 6 | belongs_to :favoritable, polymorphic: true 7 | belongs_to :favoritor, polymorphic: true 8 | 9 | def block! 10 | update!(blocked: true) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/rails_app/app/models/favorite.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Favorite < ApplicationRecord 4 | extend ActsAsFavoritor::FavoriteScopes 5 | 6 | belongs_to :favoritable, polymorphic: true 7 | belongs_to :favoritor, polymorphic: true 8 | 9 | def block! 10 | update!(blocked: true) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | FactoryBot.define do 4 | factory :jon, class: 'User' do |user| 5 | user.name { 'Jon' } 6 | end 7 | 8 | factory :sam, class: 'User' do |user| 9 | user.name { 'Sam' } 10 | end 11 | 12 | factory :bob, class: 'User' do |user| 13 | user.name { 'Bob' } 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/rails_app/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | begin 5 | load File.expand_path('spring', __dir__) 6 | rescue LoadError => e 7 | raise unless e.message.include?('spring') 8 | end 9 | APP_PATH = File.expand_path('../config/application', __dir__) 10 | require_relative '../config/boot' 11 | require 'rails/commands' 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | This file tracks all unreleased breaking changes and deprecations on `master`. You can find a list of all releases [here](https://github.com/jonhue/acts_as_favoritor/releases). 4 | 5 | acts_as_favoritor follows Semantic Versioning 2.0 as defined at http://semver.org. 6 | 7 | ### Breaking Changes 8 | 9 | * None 10 | 11 | ### Deprecated 12 | 13 | * None 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'activesupport', ENV.fetch('RAILS_VERSION', nil) if ENV['RAILS_VERSION'] 8 | gem 'factory_bot' 9 | gem 'pry' 10 | gem 'rails' 11 | gem 'rspec-rails' 12 | gem 'rubocop' 13 | gem 'rubocop-factory_bot' 14 | gem 'rubocop-rails' 15 | gem 'rubocop-rspec' 16 | gem 'rubocop-rspec_rails' 17 | gem 'sqlite3' 18 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/railtie' 4 | 5 | module ActsAsFavoritor 6 | class Railtie < Rails::Railtie 7 | initializer 'acts_as_favoritor.active_record' do 8 | ActiveSupport.on_load :active_record do 9 | include ActsAsFavoritor::Favoritor 10 | include ActsAsFavoritor::Favoritable 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/generators/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActsAsFavoritor.configure do |config| 4 | # Specify your default scope. Learn more about scopes here: https://github.com/jonhue/acts_as_favoritor#scopes 5 | # config.default_scope = :favorite 6 | 7 | # Enable caching. Learn more about caching here: https://github.com/jonhue/acts_as_favoritor#caching 8 | # config.cache = false 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/initializers/acts_as_favoritor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActsAsFavoritor.configure do |config| 4 | # Specify your default scope. Learn more about scopes here: https://github.com/jonhue/acts_as_favoritor#scopes 5 | # config.default_scope = 'favorite' 6 | 7 | # Enable caching. Learn more about caching here: https://github.com/jonhue/acts_as_favoritor#caching 8 | # config.cache = false 9 | end 10 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/favoritor_lib.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsFavoritor 4 | module FavoritorLib 5 | def build_result_for_scopes(scopes, &block) 6 | return yield(scopes) unless scopes.is_a?(Array) 7 | return if scopes.empty? 8 | 9 | sanitized_scopes(scopes).index_with(&block) 10 | end 11 | 12 | private 13 | 14 | def sanitized_scopes(scopes) 15 | scopes.map(&:to_sym) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'acts_as_favoritor/version' 4 | 5 | module ActsAsFavoritor 6 | require_relative 'acts_as_favoritor/configuration' 7 | 8 | autoload :Favoritor, 'acts_as_favoritor/favoritor' 9 | autoload :Favoritable, 'acts_as_favoritor/favoritable' 10 | autoload :FavoritorLib, 'acts_as_favoritor/favoritor_lib' 11 | autoload :FavoriteScopes, 'acts_as_favoritor/favorite_scopes' 12 | 13 | require_relative 'acts_as_favoritor/railtie' if defined?(Rails::Railtie) 14 | end 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. ... 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Environment** 21 | Please mention your environment as well as the version of the package you are using here. 22 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsFavoritor 4 | class << self 5 | attr_accessor :configuration 6 | end 7 | 8 | def self.configure 9 | self.configuration ||= Configuration.new 10 | yield configuration 11 | end 12 | 13 | class Configuration 14 | DEFAULT_SCOPE = :favorite 15 | DEFAULT_CACHE = false 16 | 17 | attr_accessor :cache, :default_scope 18 | 19 | def initialize 20 | @default_scope = DEFAULT_SCOPE 21 | @cache = DEFAULT_CACHE 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/all' 4 | 5 | require 'factory_bot' 6 | require 'rspec/rails' 7 | 8 | ENV['RAILS_ENV'] = 'test' 9 | require 'support/rails_app/config/environment' 10 | 11 | ActiveRecord::Migration.maintain_test_schema! 12 | ActiveRecord::Schema.verbose = false 13 | load 'support/rails_app/db/schema.rb' 14 | 15 | require 'spec_helper' 16 | 17 | RSpec.configure do |config| 18 | config.include FactoryBot::Syntax::Methods 19 | 20 | config.before :suite do 21 | FactoryBot.find_definitions 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite version 3.x 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: ruby/setup-ruby@v1 15 | with: 16 | ruby-version: '3.1' 17 | 18 | - name: Publish to RubyGems 19 | run: | 20 | mkdir -p $HOME/.gem 21 | touch $HOME/.gem/credentials 22 | chmod 0600 $HOME/.gem/credentials 23 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 24 | gem build *.gemspec 25 | gem push *.gem 26 | env: 27 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Suggested implementation**: 20 | In case you already have ideas for implementation, leave them here. 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | ruby: 12 | - '3.0' 13 | - '3.1' 14 | - '3.2' 15 | - '3.3' 16 | 17 | name: Ruby ${{ matrix.ruby }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: ${{ matrix.ruby }} 23 | - name: Install dependencies 24 | run: | 25 | sudo apt-get update 26 | sudo apt-get install libsqlite3-dev 27 | gem install bundler 28 | bundle install --jobs 4 --retry 3 29 | - name: Run RuboCop 30 | run: bundle exec rubocop 31 | - name: Run RSpec specs 32 | run: bundle exec rspec 33 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'boot' 4 | 5 | require 'rails/all' 6 | 7 | # Require the gems listed in Gemfile, including any gems 8 | # you've limited to :test, :development, or :production. 9 | Bundler.require(*Rails.groups) 10 | 11 | module RailsApp 12 | class Application < Rails::Application 13 | # Initialize configuration defaults for originally generated Rails version. 14 | config.load_defaults 5.2 15 | 16 | # Settings in config/environments/* take precedence over those specified 17 | # here. 18 | # Application configuration can go into files in config/initializers 19 | # -- all .rb files in that directory are automatically loaded after loading 20 | # the framework and any gems in your application. 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/lib/acts_as_favoritor/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ActsAsFavoritor::Configuration do 4 | let(:config) { ActsAsFavoritor.configuration } 5 | 6 | after do 7 | ActsAsFavoritor.configure do |config| 8 | config.default_scope = :favorite 9 | config.cache = false 10 | end 11 | end 12 | 13 | it 'has defaults set for the configuration options' do 14 | expect(config.default_scope).to eq :favorite 15 | expect(config.cache).to be false 16 | end 17 | 18 | it 'allows configuring the gem' do 19 | ActsAsFavoritor.configure do |config| 20 | config.default_scope = :friend 21 | config.cache = true 22 | end 23 | 24 | expect(config.default_scope).to eq :friend 25 | expect(config.cache).to be true 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/acts_as_favoritor/favoritor_lib_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe ActsAsFavoritor::FavoritorLib do 4 | describe 'build_result_for_scopes' do 5 | it 'returns nil when scopes is empty' do 6 | expect(Dummy.build_result_for_scopes([])).to be_nil 7 | end 8 | 9 | it 'returns result of the block when a single scope is given' do 10 | expect(Dummy.build_result_for_scopes(:favorite) do |scope| 11 | scope.to_s.upcase 12 | end).to eq 'FAVORITE' 13 | end 14 | 15 | it 'returns a hash with scopes as keys ' \ 16 | 'and the results of the block as values' do 17 | expect(Dummy.build_result_for_scopes([:favorite, :friend]) do |scope| 18 | scope.to_s.upcase 19 | end).to eq(favorite: 'FAVORITE', friend: 'FRIEND') 20 | end 21 | end 22 | end 23 | 24 | class Dummy 25 | extend ActsAsFavoritor::FavoritorLib 26 | end 27 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | - package-ecosystem: "bundler" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | ignore: 17 | - dependency-name: "*" 18 | update-types: ["version-update:semver-patch"] 19 | - package-ecosystem: "bundler" 20 | directory: "/spec/support/rails_app" 21 | schedule: 22 | interval: "daily" 23 | ignore: 24 | - dependency-name: "*" 25 | update-types: ["version-update:semver-patch"] 26 | -------------------------------------------------------------------------------- /spec/support/rails_app/db/migrate/20190504084849_acts_as_favoritor_migration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActsAsFavoritorMigration < ActiveRecord::Migration[5.2] 4 | def self.up 5 | create_table :favorites, force: true do |t| 6 | t.references :favoritable, polymorphic: true, null: false 7 | t.references :favoritor, polymorphic: true, null: false 8 | t.string :scope, default: ActsAsFavoritor.configuration.default_scope, 9 | null: false, 10 | index: true 11 | t.boolean :blocked, default: false, null: false, index: true 12 | t.timestamps 13 | end 14 | 15 | add_index :favorites, 16 | ['favoritor_id', 'favoritor_type'], 17 | name: 'fk_favorites' 18 | add_index :favorites, 19 | ['favoritable_id', 'favoritable_type'], 20 | name: 'fk_favoritables' 21 | end 22 | 23 | def self.down 24 | drop_table :favorites 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at http://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-factory_bot 3 | - rubocop-rails 4 | - rubocop-rspec 5 | - rubocop-rspec_rails 6 | 7 | AllCops: 8 | Exclude: 9 | - spec/support/rails_app/db/schema.rb 10 | - vendor/**/* 11 | NewCops: enable 12 | TargetRubyVersion: 3.0 13 | 14 | Gemspec/RequireMFA: 15 | Enabled: false 16 | 17 | Gemspec/RequiredRubyVersion: 18 | Enabled: false 19 | 20 | Layout/MultilineMethodCallIndentation: 21 | Exclude: 22 | - spec/**/*_spec.rb 23 | 24 | Metrics/BlockLength: 25 | Exclude: 26 | - spec/**/*_spec.rb 27 | 28 | Metrics/MethodLength: 29 | Exclude: 30 | - spec/support/rails_app/db/migrate/**.rb 31 | 32 | RSpec/ChangeByZero: 33 | Enabled: false 34 | 35 | RSpec/DescribeClass: 36 | Exclude: 37 | - spec/integration/**/*_spec.rb 38 | 39 | RSpec/ExampleLength: 40 | Enabled: false 41 | 42 | RSpec/MultipleExpectations: 43 | Enabled: false 44 | 45 | Style/Documentation: 46 | Enabled: false 47 | 48 | Style/PerlBackrefs: 49 | Enabled: false 50 | 51 | Style/SymbolArray: 52 | EnforcedStyle: brackets 53 | 54 | Style/WordArray: 55 | EnforcedStyle: brackets 56 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | These versions are currently being supported with security updates. 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 6.x | :white_check_mark: | 10 | | 5.x | :x: | 11 | | 4.x | :x: | 12 | | 3.x | :x: | 13 | | 2.x | :x: | 14 | | 1.x | :x: | 15 | 16 | ## Reporting a Vulnerability 17 | 18 | If you found a security vulnerability either with this project or with a project this project depends on, don't hesitate to send an email to me at jonas.huebotter@gmail.com. 19 | Your report should contain a summary of your findings and instructions to reproduce the vulnerability. 20 | 21 | Once you reported a security vulnerability I will attempt to get back to you as soon as possible. 22 | In the case of a security vulnerability of a dependency please also report the vulnerability to the projects maintainer. 23 | I will most likely not be able to immediately address vulnerabilities stemming from dependencies (unless they were introduced in a recent release). 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jonas Hübotter 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/generators/templates/migration.rb.erb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActsAsFavoritorMigration < ActiveRecord::Migration<%= migration_version %> 4 | def self.up 5 | create_table :favorites, force: true do |t| 6 | t.references :favoritable, polymorphic: true, null: false 7 | t.references :favoritor, polymorphic: true, null: false 8 | t.string :scope, default: ActsAsFavoritor.configuration.default_scope, 9 | null: false, 10 | index: true 11 | t.boolean :blocked, default: false, null: false, index: true 12 | t.timestamps 13 | end 14 | 15 | add_index :favorites, 16 | ['favoritor_id', 'favoritor_type'], 17 | name: 'fk_favorites' 18 | add_index :favorites, 19 | ['favoritable_id', 'favoritable_type'], 20 | name: 'fk_favoritables' 21 | add_index :favorites, 22 | ['favoritable_type', 'favoritable_id', 'favoritor_type', 23 | 'favoritor_id', 'scope'], 24 | name: 'uniq_favorites__and_favoritables', unique: true 25 | end 26 | 27 | def self.down 28 | drop_table :favorites 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '19 22 * * 5' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'ruby' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v4 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v3 33 | with: 34 | languages: ${{ matrix.language }} 35 | # If you wish to specify custom queries, you can do so here or in a config file. 36 | # By default, queries listed here will override any specified in a config file. 37 | # Prefix the list here with "+" to use these queries and those in the config file. 38 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 39 | 40 | - name: Perform CodeQL Analysis 41 | uses: github/codeql-action/analyze@v3 42 | -------------------------------------------------------------------------------- /lib/generators/acts_as_favoritor_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails/generators' 4 | require 'rails/generators/migration' 5 | 6 | class ActsAsFavoritorGenerator < Rails::Generators::Base 7 | include Rails::Generators::Migration 8 | 9 | source_root(File.join(File.dirname(__FILE__), 'templates')) 10 | desc 'Install acts_as_favoritor' 11 | 12 | def self.timestamped_migrations 13 | if ActiveRecord.respond_to?(:timestamped_migrations) 14 | ActiveRecord.timestamped_migrations 15 | elsif ActiveRecord::Base.respond_to?(:timestamped_migrations) 16 | ActiveRecord::Base.timestamped_migrations 17 | end 18 | end 19 | 20 | def self.next_migration_number(dirname) 21 | if timestamped_migrations 22 | Time.now.utc.strftime('%Y%m%d%H%M%S') 23 | else 24 | format('%.3d', 25 | migration_number: current_migration_number(dirname) + 1) 26 | end 27 | end 28 | 29 | def create_initializer 30 | template 'initializer.rb', 'config/initializers/acts_as_favoritor.rb' 31 | end 32 | 33 | def create_migration_file 34 | migration_template( 35 | 'migration.rb.erb', 36 | 'db/migrate/acts_as_favoritor_migration.rb', 37 | migration_version: migration_version 38 | ) 39 | end 40 | 41 | def create_model 42 | template 'model.rb', 'app/models/favorite.rb' 43 | end 44 | 45 | private 46 | 47 | def migration_version 48 | return unless Rails.version >= '5.0.0' 49 | 50 | "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]" 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/favorite_scopes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsFavoritor 4 | module FavoriteScopes 5 | DEFAULT_PARENTS = [ApplicationRecord, ActiveRecord::Base].freeze 6 | 7 | def method_missing(method, *args) 8 | if method.to_s[/(.+)_list/] 9 | where(scope: $1.singularize.to_sym) 10 | else 11 | super 12 | end 13 | end 14 | 15 | def respond_to_missing?(method, include_private = false) 16 | super || method.to_s[/(.+)_list/] 17 | end 18 | 19 | def for_favoritor(favoritor) 20 | where( 21 | favoritor_id: favoritor.id, 22 | favoritor_type: parent_class_name(favoritor) 23 | ) 24 | end 25 | 26 | def for_favoritable(favoritable) 27 | where( 28 | favoritable_id: favoritable.id, 29 | favoritable_type: parent_class_name(favoritable) 30 | ) 31 | end 32 | 33 | def for_favoritor_type(favoritor_type) 34 | where(favoritor_type: favoritor_type) 35 | end 36 | 37 | def for_favoritable_type(favoritable_type) 38 | where(favoritable_type: favoritable_type) 39 | end 40 | 41 | def unblocked 42 | where(blocked: false) 43 | end 44 | 45 | def blocked 46 | where(blocked: true) 47 | end 48 | 49 | private 50 | 51 | def parent_class_name(object) 52 | if DEFAULT_PARENTS.include?(object.class.superclass) || 53 | !object.class.respond_to?(:base_class) 54 | return object.class.name 55 | end 56 | 57 | object.class.base_class.name 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /acts_as_favoritor.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path(File.join('..', 'lib', 'acts_as_favoritor', 'version'), __FILE__) 4 | 5 | Gem::Specification.new do |gem| 6 | gem.name = 'acts_as_favoritor' 7 | gem.version = ActsAsFavoritor::VERSION 8 | gem.platform = Gem::Platform::RUBY 9 | gem.summary = 'A Rubygem to add Favorite, Follow, Vote, etc. ' \ 10 | 'functionality to ActiveRecord models' 11 | gem.description = 'acts_as_favoritor is a Rubygem to allow any ' \ 12 | 'ActiveRecord model to associate any other ' \ 13 | 'model including the option for multiple ' \ 14 | 'relationships per association with scopes. You ' \ 15 | 'are able to differentiate followers, ' \ 16 | 'favorites, watchers, votes and whatever else ' \ 17 | 'you can imagine through a single relationship. ' \ 18 | 'This is accomplished by a double polymorphic ' \ 19 | 'relationship on the Favorite model. There is ' \ 20 | 'also built in support for blocking/un-blocking ' \ 21 | 'favorite records as well as caching.' 22 | gem.authors = 'Jonas Hübotter' 23 | gem.email = 'jonas.huebotter@gmail.com' 24 | gem.homepage = 'https://github.com/jonhue/acts_as_favoritor' 25 | gem.license = 'MIT' 26 | 27 | gem.files = Dir['README.md', 'LICENSE', 'lib/**/*'] 28 | gem.require_paths = ['lib'] 29 | 30 | gem.required_ruby_version = '>= 3.0' 31 | 32 | gem.add_dependency 'activerecord', '>= 5.0' 33 | end 34 | -------------------------------------------------------------------------------- /spec/support/rails_app/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2019_05_04_084849) do 14 | 15 | create_table "composers", force: :cascade do |t| 16 | t.string "name" 17 | end 18 | 19 | create_table "favorites", force: :cascade do |t| 20 | t.string "favoritable_type", null: false 21 | t.integer "favoritable_id", null: false 22 | t.string "favoritor_type", null: false 23 | t.integer "favoritor_id", null: false 24 | t.string "scope", default: "favorite", null: false 25 | t.boolean "blocked", default: false, null: false 26 | t.datetime "created_at", null: false 27 | t.datetime "updated_at", null: false 28 | t.index ["blocked"], name: "index_favorites_on_blocked" 29 | t.index ["favoritable_id", "favoritable_type"], name: "fk_favoritables" 30 | t.index ["favoritable_type", "favoritable_id"], name: "index_favorites_on_favoritable_type_and_favoritable_id" 31 | t.index ["favoritor_id", "favoritor_type"], name: "fk_favorites" 32 | t.index ["favoritor_type", "favoritor_id"], name: "index_favorites_on_favoritor_type_and_favoritor_id" 33 | t.index ["scope"], name: "index_favorites_on_scope" 34 | end 35 | 36 | create_table "users", force: :cascade do |t| 37 | t.string "name" 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in 5 | # config/application.rb. 6 | 7 | # The test environment is used exclusively to run your application's 8 | # test suite. You never need to work with it otherwise. Remember that 9 | # your test database is "scratch space" for the test suite and is wiped 10 | # and recreated between test runs. Don't rely on the data there! 11 | config.cache_classes = true 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | 28 | # Raise exceptions instead of rendering exception templates. 29 | config.action_dispatch.show_exceptions = false 30 | 31 | # Disable request forgery protection in test environment. 32 | config.action_controller.allow_forgery_protection = false 33 | 34 | # # Store uploaded files on the local file system in a temporary directory 35 | # config.active_storage.service = :test 36 | 37 | # config.action_mailer.perform_caching = false 38 | # 39 | # # Tell Action Mailer not to deliver emails to the real world. 40 | # # The :test delivery method accumulates sent emails in the 41 | # # ActionMailer::Base.deliveries array. 42 | # config.action_mailer.delivery_method = :test 43 | 44 | # Print deprecation notices to the stderr. 45 | config.active_support.deprecation = :stderr 46 | 47 | # Raises error for missing translations 48 | # config.action_view.raise_on_missing_translations = true 49 | end 50 | -------------------------------------------------------------------------------- /spec/support/rails_app/Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | ruby '3.0.0' 7 | 8 | # Bundle edge Rails instead: gem 'rails', github: 'rails/rails' 9 | gem 'rails', '~> 7.1.5' 10 | # Use sqlite3 as the database for Active Record 11 | gem 'sqlite3' 12 | # Use Puma as the app server 13 | gem 'puma', '~> 6.5' 14 | # Use SCSS for stylesheets 15 | gem 'sass-rails', '~> 6.0' 16 | # Use Uglifier as compressor for JavaScript assets 17 | gem 'uglifier', '>= 1.3.0' 18 | # See https://github.com/rails/execjs#readme for more supported runtimes 19 | # gem 'mini_racer', platforms: :ruby 20 | 21 | # Use CoffeeScript for .coffee assets and views 22 | gem 'coffee-rails', '~> 5.0' 23 | # Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks 24 | gem 'turbolinks', '~> 5' 25 | # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder 26 | gem 'jbuilder', '~> 2.13' 27 | # Use Redis adapter to run Action Cable in production 28 | # gem 'redis', '~> 4.0' 29 | # Use ActiveModel has_secure_password 30 | # gem 'bcrypt', '~> 3.1.7' 31 | 32 | # Use ActiveStorage variant 33 | # gem 'mini_magick', '~> 4.8' 34 | 35 | # Use Capistrano for deployment 36 | # gem 'capistrano-rails', group: :development 37 | 38 | # Reduces boot times through caching; required in config/boot.rb 39 | gem 'bootsnap', '>= 1.1.0', require: false 40 | 41 | group :development, :test do 42 | # Call 'byebug' anywhere in the code to stop execution and get a debugger 43 | # console 44 | gem 'byebug', platforms: [:mri, :mingw, :x64_mingw] 45 | end 46 | 47 | group :development do 48 | gem 'listen', '>= 3.0.5', '< 3.10' 49 | # Spring speeds up development by keeping your application running in the 50 | # background. Read more: https://github.com/rails/spring 51 | gem 'spring' 52 | gem 'spring-watcher-listen', '~> 2.1.0' 53 | # Access an interactive console on exception pages or by calling 'console' 54 | # anywhere in the code. 55 | gem 'web-console', '>= 3.3.0' 56 | end 57 | 58 | group :test do 59 | # Adds support for Capybara system testing and selenium driver 60 | gem 'capybara', '>= 2.15' 61 | gem 'selenium-webdriver' 62 | # Easy installation and use of chromedriver to run system tests with Chrome 63 | gem 'chromedriver-helper' 64 | end 65 | 66 | # Windows does not include zoneinfo files, so bundle the tzinfo-data gem 67 | gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] 68 | -------------------------------------------------------------------------------- /spec/support/rails_app/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in 5 | # config/application.rb. 6 | 7 | # In the development environment your application's code is reloaded on 8 | # every request. This slows down response time but is perfect for development 9 | # since you don't have to restart the web server when you make code changes. 10 | config.cache_classes = false 11 | 12 | # Do not eager load code on boot. 13 | config.eager_load = false 14 | 15 | # Show full error reports. 16 | config.consider_all_requests_local = true 17 | 18 | # Enable/disable caching. By default caching is disabled. 19 | # Run rails dev:cache to toggle caching. 20 | if Rails.root.join('tmp/caching-dev.txt').exist? 21 | config.action_controller.perform_caching = true 22 | 23 | config.cache_store = :memory_store 24 | config.public_file_server.headers = { 25 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 26 | } 27 | else 28 | config.action_controller.perform_caching = false 29 | 30 | config.cache_store = :null_store 31 | end 32 | 33 | # # Store uploaded files on the local file system (see config/storage.yml for 34 | # # options) 35 | # config.active_storage.service = :local 36 | 37 | # # Don't care if the mailer can't send. 38 | # config.action_mailer.raise_delivery_errors = false 39 | # 40 | # config.action_mailer.perform_caching = false 41 | 42 | # Print deprecation notices to the Rails logger. 43 | config.active_support.deprecation = :log 44 | 45 | # Raise an error on page load if there are pending migrations. 46 | config.active_record.migration_error = :page_load 47 | 48 | # Highlight code that triggered database queries in logs. 49 | config.active_record.verbose_query_logs = true 50 | 51 | # Debug mode disables concatenation and preprocessing of assets. 52 | # This option may cause significant delays in view rendering with a large 53 | # number of complex assets. 54 | config.assets.debug = true 55 | 56 | # Suppress logger output for asset requests. 57 | config.assets.quiet = true 58 | 59 | # Raises error for missing translations 60 | # config.action_view.raise_on_missing_translations = true 61 | 62 | # Use an evented file watcher to asynchronously detect changes in source code, 63 | # routes, locales, etc. This feature depends on the listen gem. 64 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 65 | end 66 | -------------------------------------------------------------------------------- /spec/lib/acts_as_favoritor/favorite_scopes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../rails_helper' 4 | 5 | RSpec.describe ActsAsFavoritor::FavoriteScopes do 6 | let(:sam) { create(:sam) } 7 | let(:jon) { create(:jon) } 8 | let(:beethoven) { create(:beethoven) } 9 | 10 | before do 11 | Favorite.delete_all 12 | 13 | sam.favorite(jon) 14 | sam.favorite(beethoven) 15 | jon.favorite(sam) 16 | beethoven.favorite(jon) 17 | end 18 | 19 | describe 'for_favoritor' do 20 | it 'returns favorites of the given favoritor' do 21 | expect(Favorite.for_favoritor(sam)) 22 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon), 23 | Favorite.find_by(favoritor: sam, favoritable: beethoven)] 24 | end 25 | end 26 | 27 | describe 'for_favoritable' do 28 | it 'returns favorites with the given favoritable' do 29 | expect(Favorite.for_favoritable(sam)) 30 | .to eq [Favorite.find_by(favoritor: jon, favoritable: sam)] 31 | end 32 | end 33 | 34 | describe 'for_favoritor_type' do 35 | it 'returns favorites of favoritors with the given type' do 36 | expect(Favorite.for_favoritor_type('User')) 37 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon), 38 | Favorite.find_by(favoritor: sam, favoritable: beethoven), 39 | Favorite.find_by(favoritor: jon, favoritable: sam)] 40 | end 41 | end 42 | 43 | describe 'for_favoritable_type' do 44 | it 'returns favorites with favoritables of the given type' do 45 | expect(Favorite.for_favoritable_type('User')) 46 | .to eq [Favorite.find_by(favoritor: jon, favoritable: sam), 47 | Favorite.find_by(favoritor: sam, favoritable: jon), 48 | Favorite.find_by(favoritor: beethoven, favoritable: jon)] 49 | end 50 | end 51 | 52 | context 'with block/unblock' do 53 | before { jon.block(beethoven) } 54 | 55 | describe 'unblocked' do 56 | it 'returns unblocked favorites' do 57 | expect(Favorite.unblocked) 58 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon), 59 | Favorite.find_by(favoritor: sam, favoritable: beethoven), 60 | Favorite.find_by(favoritor: jon, favoritable: sam)] 61 | end 62 | end 63 | 64 | describe 'blocked' do 65 | it 'returns blocked favorites' do 66 | expect(Favorite.blocked) 67 | .to eq [Favorite.find_by(favoritor: beethoven, favoritable: jon)] 68 | end 69 | end 70 | end 71 | 72 | context 'with magic methods' do 73 | it 'responds to magic methods' do 74 | expect(Favorite).to respond_to(:favorite_list) 75 | end 76 | 77 | it 'still raises a NoMethodError' do 78 | expect { Favorite.foobar }.to raise_error(NoMethodError) 79 | end 80 | 81 | it '*_list returns favorites with the given scope' do 82 | jon.favorite(sam, scope: :friend) 83 | 84 | expect(Favorite.friend_list) 85 | .to eq [Favorite.find_by(favoritor: jon, favoritable: sam, 86 | scope: :friend)] 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at me@jonhue.me. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you in showing interest in contributing to acts_as_favoritor! We greatly value every contribution from 4 | 5 | * fixing a syntax error, improving style or adding a paragraph; 6 | * adding to the documentation; 7 | * reporting a bug; 8 | * implementing a feature; to 9 | * suggesting a new feature. 10 | 11 | ## Code of Conduct 12 | 13 | This project and everyone participating in it is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. 14 | 15 | ## Contributing ideas and reporting bugs 16 | 17 | We use GitHub issues as a ticket system. You should open an issue if 18 | 19 | * you want to [report a bug](https://github.com/tony-lang/tree-sitter-tony/issues/new?assignees=&labels=bug&template=bug_report.md&title=); 20 | * you want to [suggest a feature](https://github.com/tony-lang/tree-sitter-tony/issues/new?assignees=&labels=discussion%2C+enhancement&template=feature_request.md&title=); or 21 | * you want to [make changes to the documentation](https://github.com/tony-lang/tree-sitter-tony/issues/new?assignees=&labels=documentation&template=documentation.md&title=) (that go beyond fixing a syntax error here or adding a paragraph there). 22 | 23 | You should **not** open an issue if 24 | 25 | * you want to report a security vulnerability. In that case please reference the [security policy](SECURITY.md); or 26 | * you have a question. If you have a question use [Stack Overflow](https://stackoverflow.com) instead or send an email to jonas.huebotter@gmail.com. 27 | * you want to fix a syntax error or adjust a sentence in the documentation. You may immediately propose your changes in a pull request. 28 | 29 | When you open an issue we urge you to use the provided templates. 30 | 31 | ## Contributing code 32 | 33 | We appreciate your interest in contributing code. We flag issues we seek help with with the `help wanted` label. Issues that are good for first-time contributors are flagged with the `good first issue` label. 34 | 35 | If you want to contribute code for an issue that is not flagged with one of these labels, ask kindly in the relevant issue. We will most likely appreciate your help with these tickets as well. Bear in mind that in this case, working on the ticket might require a little more direct communication beforehand. 36 | 37 | In case you have any questions on an issue you want to take over don't hesitate to tag an owner of this project in your question in the issue timeline. 38 | 39 | If you want to contribute code on something that doesn't have a belonging issue yet, please [open an issue](#contributing-ideas-and-reporting-bugs) first. 40 | 41 | ### Writing and testing code 42 | 43 | acts_as_favoritor uses RuboCop to enforce code style and detect semantic problems with your code. 44 | RuboCop is run with every pull request you open (and subsequent commits you push). Please reference the [readme](README.md#development) and `.github/workflows/ci.yml` for details on how to run RuboCop locally. 45 | 46 | Furthermore, we strongly encourage you to write tests for every change you propose that affects actual code. Details on how tests are run can also be found in the [readme](README.md#testing). 47 | 48 | ### Submitting a pull request 49 | 50 | Once you're happy with your changes, you are ready to submit your pull request. 51 | 52 | Please keep your description short and succinct, but explain what changes you made and - if applicable - why you made those changes. 53 | The more descriptive your summary is, the more likely it is for your changes to be merged quickly. 54 | Don't forget to reference the issue this pull request addresses. 55 | 56 | Don't be taken aback if your pull request is not merged immediately. Most pull requests take a couple of rounds of iteration until everyone is happy with all changes made. 57 | 58 | Once your pull request got merged, we're absolutely delighted to welcome you as a contributor to acts_as_favoritor! 59 | -------------------------------------------------------------------------------- /spec/integration/acts_as_favoritable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../rails_helper' 4 | 5 | RSpec.describe 'acts_as_favoritable' do 6 | let(:sam) { create(:sam) } 7 | let(:jon) { create(:jon) } 8 | let(:bob) { create(:bob) } 9 | 10 | context 'without scopes' do 11 | before do 12 | jon.favorite(sam) 13 | bob.favorite(sam) 14 | sam.favorite(jon) 15 | end 16 | 17 | it 'responds to instance methods' do 18 | expect(sam).to respond_to(:favoritors) 19 | expect(sam).to respond_to(:favorited_by_type) 20 | expect(sam).to respond_to(:favorited_by?) 21 | expect(sam).to respond_to(:block) 22 | expect(sam).to respond_to(:unblock) 23 | expect(sam).to respond_to(:blocked?) 24 | expect(sam).to respond_to(:blocked) 25 | end 26 | 27 | describe 'favoritors' do 28 | it 'returns favorited objects' do 29 | expect(jon.favoritors).to eq [sam] 30 | expect(sam.favoritors).to eq [jon, bob] 31 | end 32 | end 33 | 34 | describe 'favorited_by?' do 35 | it 'returns true when an instance was favorited by the given object' do 36 | expect(sam.favorited_by?(jon)).to be true 37 | end 38 | 39 | it 'returns false when an instance was not favorited ' \ 40 | 'by the given object' do 41 | expect(bob.favorited_by?(jon)).to be false 42 | end 43 | end 44 | 45 | describe 'favoritors_by_type' do 46 | it 'only returns favoritors of a given type' do 47 | expect(sam.favoritors_by_type('User')).to eq [jon, bob] 48 | end 49 | end 50 | 51 | describe 'block/unblock' do 52 | it 'a favoritable cannot be favorited when the favoritor was blocked' do 53 | expect { sam.block(jon) } 54 | .to change { jon.favorited?(sam) } 55 | .from(true).to(false) 56 | expect { sam.unblock(jon) } 57 | .to change { jon.favorited?(sam) } 58 | .from(false).to(true) 59 | end 60 | end 61 | 62 | context 'with magic methods' do 63 | it 'responds to magic methods' do 64 | expect(sam).to respond_to(:user_favoritors) 65 | expect(sam).to respond_to(:count_user_favoritors) 66 | end 67 | 68 | it 'still raises a NoMethodError' do 69 | expect { sam.foobar }.to raise_error(NoMethodError) 70 | end 71 | 72 | it '*_favoritors returns favoritors' do 73 | expect(jon.user_favoritors).to eq [sam] 74 | expect(sam.user_favoritors).to eq [jon, bob] 75 | expect(bob.user_favoritors).to eq [] 76 | end 77 | end 78 | end 79 | 80 | context 'with scopes' do 81 | before do 82 | jon.favorite(sam, scopes: [:favorite, :friend]) 83 | bob.favorite(sam, scopes: [:friend]) 84 | sam.favorite(jon, scopes: [:favorite]) 85 | end 86 | 87 | describe 'favoritors' do 88 | it 'returns favorited objects by scope' do 89 | expect(jon.favoritors(scope: :favorite)).to eq [sam] 90 | expect(sam.favoritors(scope: :favorite)).to eq [jon] 91 | end 92 | end 93 | 94 | describe 'favorited_by?' do 95 | it 'returns true when an instance was favorited by the given object' do 96 | expect(sam.favorited_by?(jon, scope: :favorite)).to be true 97 | end 98 | 99 | it 'returns false when an instance was not favorited ' \ 100 | 'by the given object' do 101 | expect(sam.favorited_by?(bob, scope: :favorite)).to be false 102 | end 103 | end 104 | 105 | describe 'favoritors_by_type' do 106 | it 'only returns favoritors of a given type by scope' do 107 | expect(sam.favoritors_by_type('User', scope: :favorite)).to eq [jon] 108 | end 109 | end 110 | end 111 | 112 | context 'with cascading' do 113 | before { jon.favorite(sam) } 114 | 115 | it 'cascades when destroying the favoritor' do 116 | expect { jon.destroy } 117 | .to change(Favorite, :count).by(-1) 118 | .and change { sam.favoritors.size }.by(-1) 119 | end 120 | 121 | it 'cascades when destroying the favoritable' do 122 | expect { sam.destroy } 123 | .to change(Favorite, :count).by(-1) 124 | .and change { sam.favoritors.size }.by(-1) 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/favoritable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsFavoritor 4 | module Favoritable 5 | def self.included(base) 6 | base.extend(ClassMethods) 7 | end 8 | 9 | module ClassMethods 10 | def acts_as_favoritable 11 | if ActsAsFavoritor.configuration&.cache 12 | serialize :favoritable_score, type: Hash 13 | serialize :favoritable_total, type: Hash 14 | end 15 | 16 | has_many :favorited, as: :favoritable, dependent: :destroy, 17 | class_name: 'Favorite' 18 | 19 | extend ActsAsFavoritor::FavoritorLib 20 | include ActsAsFavoritor::Favoritable::InstanceMethods 21 | end 22 | end 23 | 24 | module InstanceMethods 25 | # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 26 | def method_missing(method, *args) 27 | if method.to_s[/(.+)_favoritors/] 28 | favoritors_by_type($1.singularize.classify) 29 | elsif ActsAsFavoritor.configuration.cache && 30 | method.to_s[/favoritable_(.+)_score/] 31 | favoritable_score[$1.singularize.classify] 32 | elsif ActsAsFavoritor.configuration.cache && 33 | method.to_s[/favoritable_(.+)_total/] 34 | favoritable_total[$1.singularize.classify] 35 | else 36 | super 37 | end 38 | end 39 | # rubocop:enable Metrics/AbcSize, Metrics/MethodLength 40 | 41 | def respond_to_missing?(method, include_private = false) 42 | super || method.to_s[/(.+)_favoritors/] || 43 | method.to_s[/favoritable_(.+)_score/] || 44 | method.to_s[/favoritable_(.+)_total/] 45 | end 46 | 47 | def favoritors(scope: ActsAsFavoritor.configuration.default_scope, 48 | scopes: nil) 49 | self.class.build_result_for_scopes(scopes || scope) do |s| 50 | favorited.includes(:favoritor).unblocked.send("#{s}_list") 51 | .map(&:favoritor) 52 | end 53 | end 54 | 55 | def favoritors_by_type(favoritor_type, 56 | scope: ActsAsFavoritor.configuration.default_scope, 57 | scopes: nil) 58 | self.class.build_result_for_scopes(scopes || scope) do |s| 59 | favoritor_type.constantize.includes(:favorites) 60 | .where(favorites: { 61 | blocked: false, favoritable_id: id, 62 | favoritable_type: self.class.name, scope: s 63 | }) 64 | end 65 | end 66 | 67 | def favorited_by?(favoritor, 68 | scope: ActsAsFavoritor.configuration.default_scope, 69 | scopes: nil) 70 | self.class.build_result_for_scopes(scopes || scope) do |s| 71 | favorited.unblocked.send("#{s}_list").for_favoritor(favoritor) 72 | .first.present? 73 | end 74 | end 75 | 76 | def block(favoritor, scope: ActsAsFavoritor.configuration.default_scope, 77 | scopes: nil) 78 | self.class.build_result_for_scopes(scopes || scope) do |s| 79 | get_favorite_for(favoritor, s)&.block! || Favorite.create( 80 | favoritable: self, 81 | favoritor: favoritor, 82 | blocked: true, 83 | scope: scope 84 | ) 85 | end 86 | end 87 | 88 | def unblock(favoritor, scope: ActsAsFavoritor.configuration.default_scope, 89 | scopes: nil) 90 | self.class.build_result_for_scopes(scopes || scope) do |s| 91 | get_favorite_for(favoritor, s)&.update(blocked: false) 92 | end 93 | end 94 | 95 | def blocked?(favoritor, 96 | scope: ActsAsFavoritor.configuration.default_scope, 97 | scopes: nil) 98 | self.class.build_result_for_scopes(scopes || scope) do |s| 99 | favorited.blocked.send("#{s}_list").for_favoritor(favoritor).first 100 | .present? 101 | end 102 | end 103 | 104 | def blocked(scope: ActsAsFavoritor.configuration.default_scope, 105 | scopes: nil) 106 | self.class.build_result_for_scopes(scopes || scope) do |s| 107 | favorited.includes(:favoritor).blocked.send("#{s}_list") 108 | .map(&:favoritor) 109 | end 110 | end 111 | 112 | private 113 | 114 | def get_favorite_for(favoritor, scope) 115 | favorited.send("#{scope}_list").for_favoritor(favoritor).first 116 | end 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'pry' 4 | 5 | # This file was generated by the `rspec --init` command. Conventionally, all 6 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 7 | # The generated `.rspec` file contains `--require spec_helper` which will cause 8 | # this file to always be loaded, without a need to explicitly require it in any 9 | # files. 10 | # 11 | # Given that it is always loaded, you are encouraged to keep this file as 12 | # light-weight as possible. Requiring heavyweight dependencies from this file 13 | # will add to the boot time of your test suite on EVERY test run, even for an 14 | # individual file that may not need all of that loaded. Instead, consider making 15 | # a separate helper file that requires the additional dependencies and performs 16 | # the additional setup, and require it from the spec files that actually need 17 | # it. 18 | # 19 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 20 | RSpec.configure do |config| 21 | # rspec-expectations config goes here. You can use an alternate 22 | # assertion/expectation library such as wrong or the stdlib/minitest 23 | # assertions if you prefer. 24 | config.expect_with :rspec do |expectations| 25 | # This option will default to `true` in RSpec 4. It makes the `description` 26 | # and `failure_message` of custom matchers include text for helper methods 27 | # defined using `chain`, e.g.: 28 | # be_bigger_than(2).and_smaller_than(4).description 29 | # # => "be bigger than 2 and smaller than 4" 30 | # ...rather than: 31 | # # => "be bigger than 2" 32 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 33 | end 34 | 35 | # rspec-mocks config goes here. You can use an alternate test double 36 | # library (such as bogus or mocha) by changing the `mock_with` option here. 37 | config.mock_with :rspec do |mocks| 38 | # Prevents you from mocking or stubbing a method that does not exist on 39 | # a real object. This is generally recommended, and will default to 40 | # `true` in RSpec 4. 41 | mocks.verify_partial_doubles = true 42 | end 43 | 44 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 45 | # have no way to turn it off -- the option exists only for backwards 46 | # compatibility in RSpec 3). It causes shared context metadata to be 47 | # inherited by the metadata hash of host groups and examples, rather than 48 | # triggering implicit auto-inclusion in groups with matching metadata. 49 | config.shared_context_metadata_behavior = :apply_to_host_groups 50 | 51 | # # The settings below are suggested to provide a good initial experience 52 | # # with RSpec, but feel free to customize to your heart's content. 53 | # 54 | # # This allows you to limit a spec run to individual examples or groups 55 | # # you care about by tagging them with `:focus` metadata. When nothing 56 | # # is tagged with `:focus`, all examples get run. RSpec also provides 57 | # # aliases for `it`, `describe`, and `context` that include `:focus` 58 | # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 59 | # config.filter_run_when_matching :focus 60 | # 61 | # # Allows RSpec to persist some state between runs in order to support 62 | # # the `--only-failures` and `--next-failure` CLI options. We recommend 63 | # # you configure your source control system to ignore this file. 64 | # config.example_status_persistence_file_path = "spec/examples.txt" 65 | # 66 | # # Limits the available syntax to the non-monkey patched syntax that is 67 | # # recommended. For more details, see: 68 | # # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 69 | # # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 70 | # # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 71 | # config.disable_monkey_patching! 72 | # 73 | # # This setting enables warnings. It's recommended, but in some cases may 74 | # # be too noisy due to issues in dependencies. 75 | # config.warnings = true 76 | # 77 | # # Many RSpec users commonly either run the entire suite or an individual 78 | # # file, and it's useful to allow more verbose output when running an 79 | # # individual spec file. 80 | # if config.files_to_run.one? 81 | # # Use the documentation formatter for detailed output, 82 | # # unless a formatter has already been configured 83 | # # (e.g. via a command-line flag). 84 | # config.default_formatter = "doc" 85 | # end 86 | # 87 | # # Print the 10 slowest examples and example groups at the 88 | # # end of the spec run, to help surface which specs are running 89 | # # particularly slow. 90 | # config.profile_examples = 10 91 | # 92 | # # Run specs in random order to surface order dependencies. If you find an 93 | # # order dependency and want to debug it, you can fix the order by providing 94 | # # the seed, which is printed after each run. 95 | # # --seed 1234 96 | # config.order = :random 97 | # 98 | # # Seed global randomization in this process using the `--seed` CLI option. 99 | # # Setting this allows you to use `--seed` to deterministically reproduce 100 | # # test failures related to randomization by passing the same `--seed` value 101 | # # as the one that triggered the failure. 102 | # Kernel.srand config.seed 103 | end 104 | -------------------------------------------------------------------------------- /spec/lib/acts_as_favoritor/favoritable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../rails_helper' 4 | 5 | RSpec.describe ActsAsFavoritor::Favoritable do 6 | let(:sam) { create(:sam) } 7 | let(:jon) { create(:jon) } 8 | let(:beethoven) { create(:beethoven) } 9 | 10 | context 'without scopes' do 11 | before do 12 | sam.favorite(jon) 13 | sam.favorite(beethoven) 14 | jon.favorite(sam) 15 | beethoven.favorite(jon) 16 | end 17 | 18 | describe 'favorited' do 19 | it 'returns all favorite records where the given instance ' \ 20 | 'was favorited' do 21 | expect(jon.favorited) 22 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon), 23 | Favorite.find_by(favoritor: beethoven, favoritable: jon)] 24 | end 25 | end 26 | 27 | describe 'favoritors' do 28 | it 'returns favoritors who favorited the given instance' do 29 | expect(jon.favoritors).to eq [sam, beethoven] 30 | end 31 | 32 | it 'does not return blocked favoritors' do 33 | jon.block(beethoven) 34 | 35 | expect(jon.favoritors).to eq [sam] 36 | end 37 | end 38 | 39 | describe 'favoritors_by_type' do 40 | it 'returns favoritors who favorited the given instance ' \ 41 | 'and are of a specific type' do 42 | expect(jon.favoritors_by_type('User')).not_to be_a Array 43 | 44 | expect(jon.favoritors_by_type('User')).to eq [sam] 45 | expect(jon.favoritors_by_type('Composer')).to eq [beethoven] 46 | end 47 | end 48 | 49 | describe 'favorited_by?' do 50 | it 'returns true if the instance was favorited by the given record' do 51 | expect(jon.favorited_by?(beethoven)).to be true 52 | end 53 | 54 | it 'returns false if the instance was not favorited ' \ 55 | 'by the given record' do 56 | expect(beethoven.favorited_by?(jon)).to be false 57 | end 58 | end 59 | 60 | describe 'block' do 61 | it 'blocks the given favoritor' do 62 | expect { jon.block(beethoven) }.to change { jon.favoritors.size }.by(-1) 63 | end 64 | end 65 | 66 | describe 'unblock' do 67 | before { jon.block(beethoven) } 68 | 69 | it 'unblocks the given favoritor' do 70 | expect { jon.unblock(beethoven) } 71 | .to change { jon.favoritors.size }.by(1) 72 | end 73 | end 74 | 75 | describe 'blocked?' do 76 | it 'returns true if the given instance was blocked' do 77 | jon.block(beethoven) 78 | 79 | expect(jon.blocked?(beethoven)).to be true 80 | end 81 | 82 | it 'returns false if the given instance was not blocked' do 83 | expect(jon.blocked?(beethoven)).to be false 84 | end 85 | end 86 | 87 | describe 'blocked' do 88 | before { jon.block(beethoven) } 89 | 90 | it 'returns blocked favoritors' do 91 | expect(jon.blocked).to eq [beethoven] 92 | end 93 | end 94 | 95 | context 'with magic methods' do 96 | it 'responds to magic methods' do 97 | expect(jon).to respond_to(:user_favoritors) 98 | end 99 | 100 | it 'still raises a NoMethodError' do 101 | expect { jon.foobar }.to raise_error(NoMethodError) 102 | end 103 | 104 | it '*_favoritors returns favoritors of the given type' do 105 | expect(jon.user_favoritors).to eq [sam] 106 | end 107 | end 108 | end 109 | 110 | context 'with scopes' do 111 | before do 112 | sam.favorite(jon, scopes: [:friend]) 113 | sam.favorite(beethoven, scopes: [:favorite, :friend]) 114 | jon.favorite(sam, scopes: [:friend]) 115 | beethoven.favorite(jon, scopes: [:favorite]) 116 | end 117 | 118 | describe 'favoritors' do 119 | it 'returns favoritors who favorited the given instance' do 120 | expect(jon.favoritors(scope: :friend)).to eq [sam] 121 | end 122 | end 123 | 124 | describe 'favoritors_by_type' do 125 | it 'returns favoritors who favorited the given instance ' \ 126 | 'and are of a specific type' do 127 | expect(jon.favoritors_by_type('User', scope: :friend)).to eq [sam] 128 | expect(jon.favoritors_by_type('Composer', scope: :friend)).to eq [] 129 | end 130 | end 131 | 132 | describe 'favorited_by?' do 133 | it 'returns true if the instance was favorited by the given record' do 134 | expect(jon.favorited_by?(beethoven, scope: :favorite)).to be true 135 | end 136 | 137 | it 'returns false if the instance was not favorited ' \ 138 | 'by the given record' do 139 | expect(jon.favorited_by?(beethoven, scope: :friend)).to be false 140 | end 141 | end 142 | 143 | describe 'block' do 144 | it 'blocks the given favoritor' do 145 | beethoven.favorite(jon, scope: :friend) 146 | 147 | expect { jon.block(beethoven, scope: :friend) } 148 | .to change { jon.favoritors(scope: :friend).size }.by(-1) 149 | .and change { jon.favoritors(scope: :favorite).size }.by(0) 150 | end 151 | end 152 | 153 | describe 'unblock' do 154 | before { jon.block(beethoven, scope: :favorite) } 155 | 156 | it 'unblocks the given favoritor' do 157 | expect { jon.unblock(beethoven, scope: :favorite) } 158 | .to change { jon.favoritors(scope: :favorite).size }.by(1) 159 | end 160 | end 161 | 162 | describe 'blocked?' do 163 | before { jon.block(beethoven, scope: :friend) } 164 | 165 | it 'returns true if the given instance was blocked' do 166 | expect(jon.blocked?(beethoven, scope: :friend)).to be true 167 | end 168 | 169 | it 'returns false if the given instance was not blocked' do 170 | expect(jon.blocked?(beethoven, scope: :favorite)).to be false 171 | end 172 | end 173 | 174 | describe 'blocked' do 175 | before { jon.block(beethoven, scope: :friend) } 176 | 177 | it 'returns blocked favoritors' do 178 | expect(jon.blocked(scope: :friend)).to eq [beethoven] 179 | expect(jon.blocked(scope: :favorite)).to eq [] 180 | end 181 | end 182 | end 183 | end 184 | -------------------------------------------------------------------------------- /lib/acts_as_favoritor/favoritor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsFavoritor 4 | module Favoritor 5 | def self.included(base) 6 | base.extend(ClassMethods) 7 | end 8 | 9 | module ClassMethods 10 | def acts_as_favoritor 11 | if ActsAsFavoritor.configuration&.cache 12 | serialize :favoritor_score, type: Hash 13 | serialize :favoritor_total, type: Hash 14 | end 15 | 16 | has_many :favorites, as: :favoritor, dependent: :destroy 17 | 18 | extend ActsAsFavoritor::FavoritorLib 19 | include ActsAsFavoritor::Favoritor::InstanceMethods 20 | end 21 | end 22 | 23 | # rubocop:disable Metrics/ModuleLength 24 | module InstanceMethods 25 | # rubocop:disable Metrics/AbcSize, Metrics/MethodLength 26 | def method_missing(method, *args) 27 | if method.to_s[/favorited_(.+)/] 28 | favorited_by_type($1.singularize.classify) 29 | elsif ActsAsFavoritor.configuration.cache && 30 | method.to_s[/favoritor_(.+)_score/] 31 | favoritor_score[$1.singularize.classify] 32 | elsif ActsAsFavoritor.configuration.cache && 33 | method.to_s[/favoritor_(.+)_total/] 34 | favoritor_total[$1.singularize.classify] 35 | else 36 | super 37 | end 38 | end 39 | # rubocop:enable Metrics/AbcSize, Metrics/MethodLength 40 | 41 | def respond_to_missing?(method, include_private = false) 42 | super || method.to_s[/favorited_(.+)/] || 43 | method.to_s[/favoritor_(.+)_score/] || 44 | method.to_s[/favoritor_(.+)_total/] 45 | end 46 | 47 | def favorite(favoritable, 48 | scope: ActsAsFavoritor.configuration.default_scope, 49 | scopes: nil) 50 | self.class.build_result_for_scopes(scopes || scope) do |s| 51 | return nil if self == favoritable 52 | 53 | favorites.for_favoritable(favoritable).send("#{s}_list") 54 | .first_or_create! do |new_record| 55 | inc_cache(favoritable, s) if ActsAsFavoritor.configuration.cache 56 | new_record 57 | end 58 | end 59 | end 60 | 61 | def unfavorite(favoritable, 62 | scope: ActsAsFavoritor.configuration.default_scope, 63 | scopes: nil) 64 | self.class.build_result_for_scopes(scopes || scope) do |s| 65 | favorite_record = get_favorite(favoritable, s) 66 | return nil if favorite_record.blank? 67 | 68 | result = favorite_record.destroy! 69 | dec_cache(favoritable, s) if ActsAsFavoritor.configuration.cache && result.destroyed? 70 | result 71 | end 72 | end 73 | 74 | def favorited?(favoritable, 75 | scope: ActsAsFavoritor.configuration.default_scope, 76 | scopes: nil) 77 | self.class.build_result_for_scopes(scopes || scope) do |s| 78 | Favorite.unblocked.send("#{s}_list").for_favoritor(self) 79 | .for_favoritable(favoritable).size.positive? 80 | end 81 | end 82 | 83 | def all_favorites(scope: ActsAsFavoritor.configuration.default_scope, 84 | scopes: nil) 85 | self.class.build_result_for_scopes(scopes || scope) do |s| 86 | favorites.unblocked.send("#{s}_list") 87 | end 88 | end 89 | 90 | def all_favorited(scope: ActsAsFavoritor.configuration.default_scope, 91 | scopes: nil) 92 | self.class.build_result_for_scopes(scopes || scope) do |s| 93 | favorites.unblocked.send("#{s}_list").includes(:favoritable) 94 | .map(&:favoritable) 95 | end 96 | end 97 | 98 | def favorites_by_type(favoritable_type, 99 | scope: ActsAsFavoritor.configuration.default_scope, 100 | scopes: nil) 101 | self.class.build_result_for_scopes(scopes || scope) do |s| 102 | favorites.unblocked.send("#{s}_list") 103 | .for_favoritable_type(favoritable_type) 104 | end 105 | end 106 | 107 | def favorited_by_type(favoritable_type, 108 | scope: ActsAsFavoritor.configuration.default_scope, 109 | scopes: nil) 110 | self.class.build_result_for_scopes(scopes || scope) do |s| 111 | favoritable_type.constantize.includes(:favorited) 112 | .where(favorites: { 113 | blocked: false, favoritor_id: id, 114 | favoritor_type: self.class.name, scope: s 115 | }) 116 | end 117 | end 118 | 119 | def blocked_by?(favoritable, 120 | scope: ActsAsFavoritor.configuration.default_scope, 121 | scopes: nil) 122 | self.class.build_result_for_scopes(scopes || scope) do |s| 123 | Favorite.blocked.send("#{s}_list").for_favoritor(self) 124 | .for_favoritable(favoritable).size.positive? 125 | end 126 | end 127 | 128 | def blocked_by(scope: ActsAsFavoritor.configuration.default_scope, 129 | scopes: nil) 130 | self.class.build_result_for_scopes(scopes || scope) do |s| 131 | favorites.includes(:favoritable).blocked.send("#{s}_list") 132 | .map(&:favoritable) 133 | end 134 | end 135 | 136 | private 137 | 138 | def get_favorite(favoritable, scope) 139 | favorites.unblocked.send("#{scope}_list").for_favoritable(favoritable) 140 | .first 141 | end 142 | 143 | # rubocop:disable Metrics/AbcSize 144 | def inc_cache(favoritable, scope) 145 | favoritor_score[scope] = (favoritor_score[scope] || 0) + 1 146 | favoritor_total[scope] = (favoritor_total[scope] || 0) + 1 147 | save! 148 | 149 | favoritable.favoritable_score[scope] = 150 | (favoritable.favoritable_score[scope] || 0) + 1 151 | favoritable.favoritable_total[scope] = 152 | (favoritable.favoritable_total[scope] || 0) + 1 153 | favoritable.save! 154 | end 155 | 156 | def dec_cache(favoritable, scope) 157 | favoritor_score[scope] = (favoritor_score[scope] || 0) - 1 158 | favoritor_score.delete(scope) unless favoritor_score[scope].positive? 159 | save! 160 | 161 | favoritable.favoritable_score[scope] = 162 | (favoritable.favoritable_score[scope] || 0) - 1 163 | favoritable.favoritable_score.delete(scope) unless favoritable.favoritable_score[scope].positive? 164 | favoritable.save! 165 | end 166 | # rubocop:enable Metrics/AbcSize 167 | end 168 | # rubocop:enable Metrics/ModuleLength 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/integration/acts_as_favoritor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../rails_helper' 4 | 5 | RSpec.describe 'acts_as_favoritor' do 6 | let(:sam) { create(:sam) } 7 | let(:jon) { create(:jon) } 8 | let(:beethoven) { create(:beethoven) } 9 | let(:rossini) { create(:rossini) } 10 | 11 | context 'without scopes' do 12 | before do 13 | jon.favorite(sam) 14 | sam.favorite(jon) 15 | sam.favorite(beethoven) 16 | sam.favorite(rossini) 17 | end 18 | 19 | it 'responds to instance methods' do 20 | expect(sam).to respond_to(:favorite) 21 | expect(sam).to respond_to(:unfavorite) 22 | expect(sam).to respond_to(:favorited?) 23 | expect(sam).to respond_to(:all_favorites) 24 | expect(sam).to respond_to(:all_favorited) 25 | expect(sam).to respond_to(:favorites_by_type) 26 | expect(sam).to respond_to(:favorited_by_type) 27 | expect(sam).to respond_to(:blocked_by?) 28 | expect(sam).to respond_to(:blocked_by) 29 | end 30 | 31 | describe 'favorite' do 32 | it 'allows favoriting objects' do 33 | expect { jon.favorite(beethoven) } 34 | .to change(Favorite, :count).by(1) 35 | .and change { jon.all_favorites.size }.by(1) 36 | .and change { jon.favorited?(beethoven) }.from(false).to(true) 37 | end 38 | 39 | it 'cannot favorite itself' do 40 | expect { jon.favorite(jon) }.to change(Favorite, :count).by(0) 41 | .and change { jon.all_favorites.size }.by(0) 42 | expect(jon.favorited?(jon)).to be false 43 | end 44 | end 45 | 46 | describe 'unfavorite' do 47 | it 'allows removing favorites' do 48 | expect { jon.unfavorite(sam) } 49 | .to change(Favorite, :count).by(-1) 50 | .and change { jon.all_favorites.size }.by(-1) 51 | .and change { jon.favorited?(sam) }.from(true).to(false) 52 | end 53 | end 54 | 55 | describe 'favorites_by_type' do 56 | it 'only returns favorites of a given type' do 57 | expect(sam.favorites_by_type('User')) 58 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon)] 59 | expect(sam.favorites_by_type('Composer')) 60 | .to eq [Favorite.find_by(favoritor: sam, favoritable: beethoven), 61 | Favorite.find_by(favoritor: sam, favoritable: rossini)] 62 | end 63 | end 64 | 65 | describe 'all_favorites' do 66 | it 'returns all favorites' do 67 | expect(jon.all_favorites) 68 | .to eq [Favorite.find_by(favoritor: jon, favoritable: sam)] 69 | expect(sam.all_favorites) 70 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon), 71 | Favorite.find_by(favoritor: sam, favoritable: beethoven), 72 | Favorite.find_by(favoritor: sam, favoritable: rossini)] 73 | end 74 | end 75 | 76 | describe 'favorited_by_type' do 77 | it 'only returns favorited objects of a given type' do 78 | expect(sam.favorited_by_type('User')).to eq [jon] 79 | expect(sam.favorited_by_type('Composer')).to eq [beethoven, rossini] 80 | end 81 | end 82 | 83 | describe 'all_favorited' do 84 | it 'returns favorited objects' do 85 | expect(jon.all_favorited).to eq [sam] 86 | expect(sam.all_favorited).to eq [jon, beethoven, rossini] 87 | end 88 | end 89 | 90 | context 'with magic methods' do 91 | it 'responds to magic methods' do 92 | expect(sam).to respond_to(:favorited_users) 93 | expect(sam).to respond_to(:favorited_users_count) 94 | end 95 | 96 | it 'still raises a NoMethodError' do 97 | expect { sam.foobar }.to raise_error(NoMethodError) 98 | end 99 | 100 | it 'favorited_* returns favorited_by_type' do 101 | expect(sam.favorited_users).to eq [jon] 102 | expect(sam.favorited_composers).to eq [beethoven, rossini] 103 | end 104 | end 105 | end 106 | 107 | context 'with scopes' do 108 | before do 109 | jon.favorite(sam, scopes: [:favorite, :friend]) 110 | sam.favorite(jon, scopes: [:friend]) 111 | sam.favorite(beethoven, scopes: [:favorite]) 112 | sam.favorite(rossini, scopes: [:favorite]) 113 | end 114 | 115 | describe 'favorite' do 116 | it 'allows favoriting objects with scope' do 117 | expect { jon.favorite(beethoven, scope: :friend) } 118 | .to change(Favorite, :count).by(1) 119 | .and change { jon.all_favorites(scope: :friend).size }.by(1) 120 | .and change { jon.favorited?(beethoven, scope: :friend) } 121 | .from(false).to(true) 122 | 123 | expect(jon.favorited?(beethoven, scope: :favorite)).to be false 124 | end 125 | end 126 | 127 | describe 'unfavorite' do 128 | it 'allows removing favorites by scope' do 129 | expect { jon.unfavorite(sam, scope: :favorite) } 130 | .to change(Favorite, :count).by(-1) 131 | .and change { jon.all_favorites(scope: :favorite).size }.by(-1) 132 | .and change { jon.favorited?(sam, scope: :favorite) } 133 | .from(true).to(false) 134 | 135 | expect(jon.favorited?(sam, scope: :friend)).to be true 136 | end 137 | 138 | it 'allows removing multiple favorites at once' do 139 | expect { jon.unfavorite(sam, scopes: [:favorite, :friend]) } 140 | .to change(Favorite, :count).by(-2) 141 | end 142 | end 143 | 144 | describe 'favorites_by_type' do 145 | it 'only returns favorites of a given type by scope' do 146 | expect(sam.favorites_by_type('User', scope: :favorite)).to eq [] 147 | expect(sam.favorites_by_type('Composer', scope: :favorite)) 148 | .to eq [Favorite.find_by(favoritor: sam, favoritable: beethoven), 149 | Favorite.find_by(favoritor: sam, favoritable: rossini)] 150 | end 151 | end 152 | 153 | describe 'all_favorites' do 154 | it 'returns all favorites by scope' do 155 | expect(jon.all_favorites(scope: :favorite)) 156 | .to eq [Favorite.find_by(favoritor: jon, favoritable: sam)] 157 | expect(sam.all_favorites(scope: :favorite)) 158 | .to eq [Favorite.find_by(favoritor: sam, favoritable: beethoven), 159 | Favorite.find_by(favoritor: sam, favoritable: rossini)] 160 | end 161 | end 162 | 163 | describe 'favorited_by_type' do 164 | it 'only returns favorited objects of a given type' do 165 | expect(sam.favorited_by_type('User', scope: :favorite)).to eq [] 166 | expect(sam.favorited_by_type('Composer', scope: :favorite)) 167 | .to eq [beethoven, rossini] 168 | end 169 | end 170 | 171 | describe 'all_favorited' do 172 | it 'returns favorited objects' do 173 | expect(jon.all_favorited(scope: :favorite)).to eq [sam] 174 | expect(sam.all_favorited(scope: :favorite)) 175 | .to eq [beethoven, rossini] 176 | end 177 | end 178 | end 179 | 180 | context 'with cascading' do 181 | before { jon.favorite(sam) } 182 | 183 | it 'cascades when destroying the favoritor' do 184 | expect { jon.destroy } 185 | .to change(Favorite, :count).by(-1) 186 | .and change { jon.all_favorites.size }.by(-1) 187 | end 188 | 189 | it 'cascades when destroying the favoritable' do 190 | expect { sam.destroy } 191 | .to change(Favorite, :count).by(-1) 192 | .and change { jon.all_favorites.size }.by(-1) 193 | end 194 | end 195 | end 196 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | acts_as_favoritor (6.0.1) 5 | activerecord (>= 5.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.1.4.1) 11 | actionpack (= 7.1.4.1) 12 | activesupport (= 7.1.4.1) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (7.1.4.1) 17 | actionpack (= 7.1.4.1) 18 | activejob (= 7.1.4.1) 19 | activerecord (= 7.1.4.1) 20 | activestorage (= 7.1.4.1) 21 | activesupport (= 7.1.4.1) 22 | mail (>= 2.7.1) 23 | net-imap 24 | net-pop 25 | net-smtp 26 | actionmailer (7.1.4.1) 27 | actionpack (= 7.1.4.1) 28 | actionview (= 7.1.4.1) 29 | activejob (= 7.1.4.1) 30 | activesupport (= 7.1.4.1) 31 | mail (~> 2.5, >= 2.5.4) 32 | net-imap 33 | net-pop 34 | net-smtp 35 | rails-dom-testing (~> 2.2) 36 | actionpack (7.1.4.1) 37 | actionview (= 7.1.4.1) 38 | activesupport (= 7.1.4.1) 39 | nokogiri (>= 1.8.5) 40 | racc 41 | rack (>= 2.2.4) 42 | rack-session (>= 1.0.1) 43 | rack-test (>= 0.6.3) 44 | rails-dom-testing (~> 2.2) 45 | rails-html-sanitizer (~> 1.6) 46 | actiontext (7.1.4.1) 47 | actionpack (= 7.1.4.1) 48 | activerecord (= 7.1.4.1) 49 | activestorage (= 7.1.4.1) 50 | activesupport (= 7.1.4.1) 51 | globalid (>= 0.6.0) 52 | nokogiri (>= 1.8.5) 53 | actionview (7.1.4.1) 54 | activesupport (= 7.1.4.1) 55 | builder (~> 3.1) 56 | erubi (~> 1.11) 57 | rails-dom-testing (~> 2.2) 58 | rails-html-sanitizer (~> 1.6) 59 | activejob (7.1.4.1) 60 | activesupport (= 7.1.4.1) 61 | globalid (>= 0.3.6) 62 | activemodel (7.1.4.1) 63 | activesupport (= 7.1.4.1) 64 | activerecord (7.1.4.1) 65 | activemodel (= 7.1.4.1) 66 | activesupport (= 7.1.4.1) 67 | timeout (>= 0.4.0) 68 | activestorage (7.1.4.1) 69 | actionpack (= 7.1.4.1) 70 | activejob (= 7.1.4.1) 71 | activerecord (= 7.1.4.1) 72 | activesupport (= 7.1.4.1) 73 | marcel (~> 1.0) 74 | activesupport (7.1.4.1) 75 | base64 76 | bigdecimal 77 | concurrent-ruby (~> 1.0, >= 1.0.2) 78 | connection_pool (>= 2.2.5) 79 | drb 80 | i18n (>= 1.6, < 2) 81 | minitest (>= 5.1) 82 | mutex_m 83 | tzinfo (~> 2.0) 84 | ast (2.4.2) 85 | base64 (0.2.0) 86 | bigdecimal (3.1.8) 87 | builder (3.3.0) 88 | coderay (1.1.3) 89 | concurrent-ruby (1.3.4) 90 | connection_pool (2.4.1) 91 | crass (1.0.6) 92 | date (3.3.4) 93 | diff-lcs (1.5.1) 94 | drb (2.2.1) 95 | erubi (1.13.0) 96 | factory_bot (6.5.0) 97 | activesupport (>= 5.0.0) 98 | globalid (1.2.1) 99 | activesupport (>= 6.1) 100 | i18n (1.14.6) 101 | concurrent-ruby (~> 1.0) 102 | io-console (0.7.2) 103 | irb (1.14.1) 104 | rdoc (>= 4.0.0) 105 | reline (>= 0.4.2) 106 | json (2.7.2) 107 | language_server-protocol (3.17.0.3) 108 | loofah (2.22.0) 109 | crass (~> 1.0.2) 110 | nokogiri (>= 1.12.0) 111 | mail (2.8.1) 112 | mini_mime (>= 0.1.1) 113 | net-imap 114 | net-pop 115 | net-smtp 116 | marcel (1.0.4) 117 | method_source (1.1.0) 118 | mini_mime (1.1.5) 119 | minitest (5.25.1) 120 | mutex_m (0.2.0) 121 | net-imap (0.4.17) 122 | date 123 | net-protocol 124 | net-pop (0.1.2) 125 | net-protocol 126 | net-protocol (0.2.2) 127 | timeout 128 | net-smtp (0.5.0) 129 | net-protocol 130 | nio4r (2.7.3) 131 | nokogiri (1.16.7-arm64-darwin) 132 | racc (~> 1.4) 133 | nokogiri (1.16.7-x86_64-linux) 134 | racc (~> 1.4) 135 | parallel (1.26.3) 136 | parser (3.3.5.0) 137 | ast (~> 2.4.1) 138 | racc 139 | pry (0.14.2) 140 | coderay (~> 1.1) 141 | method_source (~> 1.0) 142 | psych (5.1.2) 143 | stringio 144 | racc (1.8.1) 145 | rack (3.1.8) 146 | rack-session (2.0.0) 147 | rack (>= 3.0.0) 148 | rack-test (2.1.0) 149 | rack (>= 1.3) 150 | rackup (2.1.0) 151 | rack (>= 3) 152 | webrick (~> 1.8) 153 | rails (7.1.4.1) 154 | actioncable (= 7.1.4.1) 155 | actionmailbox (= 7.1.4.1) 156 | actionmailer (= 7.1.4.1) 157 | actionpack (= 7.1.4.1) 158 | actiontext (= 7.1.4.1) 159 | actionview (= 7.1.4.1) 160 | activejob (= 7.1.4.1) 161 | activemodel (= 7.1.4.1) 162 | activerecord (= 7.1.4.1) 163 | activestorage (= 7.1.4.1) 164 | activesupport (= 7.1.4.1) 165 | bundler (>= 1.15.0) 166 | railties (= 7.1.4.1) 167 | rails-dom-testing (2.2.0) 168 | activesupport (>= 5.0.0) 169 | minitest 170 | nokogiri (>= 1.6) 171 | rails-html-sanitizer (1.6.0) 172 | loofah (~> 2.21) 173 | nokogiri (~> 1.14) 174 | railties (7.1.4.1) 175 | actionpack (= 7.1.4.1) 176 | activesupport (= 7.1.4.1) 177 | irb 178 | rackup (>= 1.0.0) 179 | rake (>= 12.2) 180 | thor (~> 1.0, >= 1.2.2) 181 | zeitwerk (~> 2.6) 182 | rainbow (3.1.1) 183 | rake (13.2.1) 184 | rdoc (6.7.0) 185 | psych (>= 4.0.0) 186 | regexp_parser (2.9.2) 187 | reline (0.5.10) 188 | io-console (~> 0.5) 189 | rspec-core (3.13.1) 190 | rspec-support (~> 3.13.0) 191 | rspec-expectations (3.13.3) 192 | diff-lcs (>= 1.2.0, < 2.0) 193 | rspec-support (~> 3.13.0) 194 | rspec-mocks (3.13.2) 195 | diff-lcs (>= 1.2.0, < 2.0) 196 | rspec-support (~> 3.13.0) 197 | rspec-rails (7.0.0) 198 | actionpack (>= 7.0) 199 | activesupport (>= 7.0) 200 | railties (>= 7.0) 201 | rspec-core (~> 3.13) 202 | rspec-expectations (~> 3.13) 203 | rspec-mocks (~> 3.13) 204 | rspec-support (~> 3.13) 205 | rspec-support (3.13.1) 206 | rubocop (1.66.0) 207 | json (~> 2.3) 208 | language_server-protocol (>= 3.17.0) 209 | parallel (~> 1.10) 210 | parser (>= 3.3.0.2) 211 | rainbow (>= 2.2.2, < 4.0) 212 | regexp_parser (>= 2.4, < 3.0) 213 | rubocop-ast (>= 1.32.1, < 2.0) 214 | ruby-progressbar (~> 1.7) 215 | unicode-display_width (>= 2.4.0, < 3.0) 216 | rubocop-ast (1.32.3) 217 | parser (>= 3.3.1.0) 218 | rubocop-factory_bot (2.26.1) 219 | rubocop (~> 1.61) 220 | rubocop-rails (2.26.0) 221 | activesupport (>= 4.2.0) 222 | rack (>= 1.1) 223 | rubocop (>= 1.52.0, < 2.0) 224 | rubocop-ast (>= 1.31.1, < 2.0) 225 | rubocop-rspec (3.0.3) 226 | rubocop (~> 1.61) 227 | rubocop-rspec_rails (2.30.0) 228 | rubocop (~> 1.61) 229 | rubocop-rspec (~> 3, >= 3.0.1) 230 | ruby-progressbar (1.13.0) 231 | sqlite3 (1.7.2-arm64-darwin) 232 | sqlite3 (1.7.2-x86_64-linux) 233 | stringio (3.1.1) 234 | thor (1.3.2) 235 | timeout (0.4.1) 236 | tzinfo (2.0.6) 237 | concurrent-ruby (~> 1.0) 238 | unicode-display_width (2.6.0) 239 | webrick (1.8.2) 240 | websocket-driver (0.7.6) 241 | websocket-extensions (>= 0.1.0) 242 | websocket-extensions (0.1.5) 243 | zeitwerk (2.6.18) 244 | 245 | PLATFORMS 246 | arm64-darwin-21 247 | arm64-darwin-22 248 | x86_64-linux 249 | 250 | DEPENDENCIES 251 | acts_as_favoritor! 252 | factory_bot 253 | pry 254 | rails 255 | rspec-rails 256 | rubocop 257 | rubocop-factory_bot 258 | rubocop-rails 259 | rubocop-rspec 260 | rubocop-rspec_rails 261 | sqlite3 262 | 263 | BUNDLED WITH 264 | 2.3.7 265 | -------------------------------------------------------------------------------- /spec/support/rails_app/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actioncable (7.1.5) 5 | actionpack (= 7.1.5) 6 | activesupport (= 7.1.5) 7 | nio4r (~> 2.0) 8 | websocket-driver (>= 0.6.1) 9 | zeitwerk (~> 2.6) 10 | actionmailbox (7.1.5) 11 | actionpack (= 7.1.5) 12 | activejob (= 7.1.5) 13 | activerecord (= 7.1.5) 14 | activestorage (= 7.1.5) 15 | activesupport (= 7.1.5) 16 | mail (>= 2.7.1) 17 | net-imap 18 | net-pop 19 | net-smtp 20 | actionmailer (7.1.5) 21 | actionpack (= 7.1.5) 22 | actionview (= 7.1.5) 23 | activejob (= 7.1.5) 24 | activesupport (= 7.1.5) 25 | mail (~> 2.5, >= 2.5.4) 26 | net-imap 27 | net-pop 28 | net-smtp 29 | rails-dom-testing (~> 2.2) 30 | actionpack (7.1.5) 31 | actionview (= 7.1.5) 32 | activesupport (= 7.1.5) 33 | nokogiri (>= 1.8.5) 34 | racc 35 | rack (>= 2.2.4) 36 | rack-session (>= 1.0.1) 37 | rack-test (>= 0.6.3) 38 | rails-dom-testing (~> 2.2) 39 | rails-html-sanitizer (~> 1.6) 40 | actiontext (7.1.5) 41 | actionpack (= 7.1.5) 42 | activerecord (= 7.1.5) 43 | activestorage (= 7.1.5) 44 | activesupport (= 7.1.5) 45 | globalid (>= 0.6.0) 46 | nokogiri (>= 1.8.5) 47 | actionview (7.1.5) 48 | activesupport (= 7.1.5) 49 | builder (~> 3.1) 50 | erubi (~> 1.11) 51 | rails-dom-testing (~> 2.2) 52 | rails-html-sanitizer (~> 1.6) 53 | activejob (7.1.5) 54 | activesupport (= 7.1.5) 55 | globalid (>= 0.3.6) 56 | activemodel (7.1.5) 57 | activesupport (= 7.1.5) 58 | activerecord (7.1.5) 59 | activemodel (= 7.1.5) 60 | activesupport (= 7.1.5) 61 | timeout (>= 0.4.0) 62 | activestorage (7.1.5) 63 | actionpack (= 7.1.5) 64 | activejob (= 7.1.5) 65 | activerecord (= 7.1.5) 66 | activesupport (= 7.1.5) 67 | marcel (~> 1.0) 68 | activesupport (7.1.5) 69 | base64 70 | benchmark (>= 0.3) 71 | bigdecimal 72 | concurrent-ruby (~> 1.0, >= 1.0.2) 73 | connection_pool (>= 2.2.5) 74 | drb 75 | i18n (>= 1.6, < 2) 76 | logger (>= 1.4.2) 77 | minitest (>= 5.1) 78 | mutex_m 79 | securerandom (>= 0.3) 80 | tzinfo (~> 2.0) 81 | addressable (2.8.6) 82 | public_suffix (>= 2.0.2, < 6.0) 83 | archive-zip (0.12.0) 84 | io-like (~> 0.3.0) 85 | base64 (0.2.0) 86 | benchmark (0.3.0) 87 | bigdecimal (3.1.8) 88 | bindex (0.8.1) 89 | bootsnap (1.18.3) 90 | msgpack (~> 1.2) 91 | builder (3.3.0) 92 | byebug (11.1.3) 93 | capybara (3.40.0) 94 | addressable 95 | matrix 96 | mini_mime (>= 0.1.3) 97 | nokogiri (~> 1.11) 98 | rack (>= 1.6.0) 99 | rack-test (>= 0.6.3) 100 | regexp_parser (>= 1.5, < 3.0) 101 | xpath (~> 3.2) 102 | chromedriver-helper (2.1.1) 103 | archive-zip (~> 0.10) 104 | nokogiri (~> 1.8) 105 | coffee-rails (5.0.0) 106 | coffee-script (>= 2.2.0) 107 | railties (>= 5.2.0) 108 | coffee-script (2.4.1) 109 | coffee-script-source 110 | execjs 111 | coffee-script-source (1.12.2) 112 | concurrent-ruby (1.3.4) 113 | connection_pool (2.4.1) 114 | crass (1.0.6) 115 | date (3.3.4) 116 | drb (2.2.1) 117 | erubi (1.13.0) 118 | execjs (2.8.1) 119 | ffi (1.16.3) 120 | globalid (1.2.1) 121 | activesupport (>= 6.1) 122 | i18n (1.14.6) 123 | concurrent-ruby (~> 1.0) 124 | io-console (0.7.2) 125 | io-like (0.3.1) 126 | irb (1.14.1) 127 | rdoc (>= 4.0.0) 128 | reline (>= 0.4.2) 129 | jbuilder (2.13.0) 130 | actionview (>= 5.0.0) 131 | activesupport (>= 5.0.0) 132 | listen (3.9.0) 133 | rb-fsevent (~> 0.10, >= 0.10.3) 134 | rb-inotify (~> 0.9, >= 0.9.10) 135 | logger (1.6.1) 136 | loofah (2.23.1) 137 | crass (~> 1.0.2) 138 | nokogiri (>= 1.12.0) 139 | mail (2.8.1) 140 | mini_mime (>= 0.1.1) 141 | net-imap 142 | net-pop 143 | net-smtp 144 | marcel (1.0.4) 145 | matrix (0.4.2) 146 | mini_mime (1.1.5) 147 | minitest (5.25.1) 148 | msgpack (1.7.2) 149 | mutex_m (0.2.0) 150 | net-imap (0.4.17) 151 | date 152 | net-protocol 153 | net-pop (0.1.2) 154 | net-protocol 155 | net-protocol (0.2.2) 156 | timeout 157 | net-smtp (0.5.0) 158 | net-protocol 159 | nio4r (2.7.4) 160 | nokogiri (1.16.7-x86_64-linux) 161 | racc (~> 1.4) 162 | psych (5.1.2) 163 | stringio 164 | public_suffix (5.0.4) 165 | puma (6.5.0) 166 | nio4r (~> 2.0) 167 | racc (1.8.1) 168 | rack (2.2.10) 169 | rack-session (1.0.2) 170 | rack (< 3) 171 | rack-test (2.1.0) 172 | rack (>= 1.3) 173 | rackup (1.0.1) 174 | rack (< 3) 175 | webrick 176 | rails (7.1.5) 177 | actioncable (= 7.1.5) 178 | actionmailbox (= 7.1.5) 179 | actionmailer (= 7.1.5) 180 | actionpack (= 7.1.5) 181 | actiontext (= 7.1.5) 182 | actionview (= 7.1.5) 183 | activejob (= 7.1.5) 184 | activemodel (= 7.1.5) 185 | activerecord (= 7.1.5) 186 | activestorage (= 7.1.5) 187 | activesupport (= 7.1.5) 188 | bundler (>= 1.15.0) 189 | railties (= 7.1.5) 190 | rails-dom-testing (2.2.0) 191 | activesupport (>= 5.0.0) 192 | minitest 193 | nokogiri (>= 1.6) 194 | rails-html-sanitizer (1.6.0) 195 | loofah (~> 2.21) 196 | nokogiri (~> 1.14) 197 | railties (7.1.5) 198 | actionpack (= 7.1.5) 199 | activesupport (= 7.1.5) 200 | irb 201 | rackup (>= 1.0.0) 202 | rake (>= 12.2) 203 | thor (~> 1.0, >= 1.2.2) 204 | zeitwerk (~> 2.6) 205 | rake (13.2.1) 206 | rb-fsevent (0.11.2) 207 | rb-inotify (0.10.1) 208 | ffi (~> 1.0) 209 | rdoc (6.7.0) 210 | psych (>= 4.0.0) 211 | regexp_parser (2.9.0) 212 | reline (0.5.10) 213 | io-console (~> 0.5) 214 | rexml (3.3.9) 215 | rubyzip (2.3.2) 216 | sass-rails (6.0.0) 217 | sassc-rails (~> 2.1, >= 2.1.1) 218 | sassc (2.4.0) 219 | ffi (~> 1.9) 220 | sassc-rails (2.1.2) 221 | railties (>= 4.0.0) 222 | sassc (>= 2.0) 223 | sprockets (> 3.0) 224 | sprockets-rails 225 | tilt 226 | securerandom (0.3.1) 227 | selenium-webdriver (4.26.0) 228 | base64 (~> 0.2) 229 | logger (~> 1.4) 230 | rexml (~> 3.2, >= 3.2.5) 231 | rubyzip (>= 1.2.2, < 3.0) 232 | websocket (~> 1.0) 233 | spring (4.2.0) 234 | spring-watcher-listen (2.1.0) 235 | listen (>= 2.7, < 4.0) 236 | spring (>= 4) 237 | sprockets (4.0.2) 238 | concurrent-ruby (~> 1.0) 239 | rack (> 1, < 3) 240 | sprockets-rails (3.4.2) 241 | actionpack (>= 5.2) 242 | activesupport (>= 5.2) 243 | sprockets (>= 3.0.0) 244 | sqlite3 (2.0.4-x86_64-linux-gnu) 245 | stringio (3.1.1) 246 | thor (1.3.2) 247 | tilt (2.0.10) 248 | timeout (0.4.1) 249 | turbolinks (5.2.1) 250 | turbolinks-source (~> 5.2) 251 | turbolinks-source (5.2.0) 252 | tzinfo (2.0.6) 253 | concurrent-ruby (~> 1.0) 254 | uglifier (4.2.0) 255 | execjs (>= 0.3.0, < 3) 256 | web-console (4.2.1) 257 | actionview (>= 6.0.0) 258 | activemodel (>= 6.0.0) 259 | bindex (>= 0.4.0) 260 | railties (>= 6.0.0) 261 | webrick (1.8.2) 262 | websocket (1.2.11) 263 | websocket-driver (0.7.6) 264 | websocket-extensions (>= 0.1.0) 265 | websocket-extensions (0.1.5) 266 | xpath (3.2.0) 267 | nokogiri (~> 1.8) 268 | zeitwerk (2.6.18) 269 | 270 | PLATFORMS 271 | x86_64-linux 272 | 273 | DEPENDENCIES 274 | bootsnap (>= 1.1.0) 275 | byebug 276 | capybara (>= 2.15) 277 | chromedriver-helper 278 | coffee-rails (~> 5.0) 279 | jbuilder (~> 2.13) 280 | listen (>= 3.0.5, < 3.10) 281 | puma (~> 6.5) 282 | rails (~> 7.1.5) 283 | sass-rails (~> 6.0) 284 | selenium-webdriver 285 | spring 286 | spring-watcher-listen (~> 2.1.0) 287 | sqlite3 288 | turbolinks (~> 5) 289 | tzinfo-data 290 | uglifier (>= 1.3.0) 291 | web-console (>= 3.3.0) 292 | 293 | RUBY VERSION 294 | ruby 3.0.0p0 295 | 296 | BUNDLED WITH 297 | 2.2.3 298 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # acts_as_favoritor 2 | 3 | acts_as_favoritor is a Rubygem to allow any ActiveRecord model to associate any other model including the option for multiple relationships per association with scopes. 4 | 5 | You are able to differentiate followers, favorites, watchers, votes and whatever else you can imagine through a single relationship. This is accomplished by a double polymorphic relationship on the Favorite model. There is also built in support for blocking/un-blocking favorite records as well as caching. 6 | 7 | [This Medium article](https://medium.com/swlh/add-dynamic-like-dislike-buttons-to-your-rails-6-application-ccce8a234c43) gives a good introduction to this gem. 8 | 9 | ## Installation 10 | 11 | You can add acts_as_favoritor to your `Gemfile` with: 12 | 13 | ```ruby 14 | gem 'acts_as_favoritor' 15 | ``` 16 | 17 | And then run: 18 | 19 | $ bundle install 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install acts_as_favoritor 24 | 25 | If you always want to be up to date fetch the latest from GitHub in your `Gemfile`: 26 | 27 | ```ruby 28 | gem 'acts_as_favoritor', github: 'jonhue/acts_as_favoritor' 29 | ``` 30 | 31 | Now run the generator: 32 | 33 | $ rails g acts_as_favoritor 34 | 35 | To wrap things up, migrate the changes into your database: 36 | 37 | $ rails db:migrate 38 | 39 | ## Usage 40 | 41 | ### Setup 42 | 43 | Add `acts_as_favoritable` to the models you want to be able to get favorited: 44 | 45 | ```ruby 46 | class User < ActiveRecord::Base 47 | acts_as_favoritable 48 | end 49 | 50 | class Book < ActiveRecord::Base 51 | acts_as_favoritable 52 | end 53 | ``` 54 | 55 | Specify which models can favorite other models by adding `acts_as_favoritor`: 56 | 57 | ```ruby 58 | class User < ActiveRecord::Base 59 | acts_as_favoritor 60 | end 61 | ``` 62 | 63 | ### `acts_as_favoritor` methods 64 | 65 | ```ruby 66 | book = Book.find(1) 67 | user = User.find(1) 68 | 69 | # `user` favorites `book`. 70 | user.favorite(book) 71 | 72 | # `user` removes `book` from favorites. 73 | user.unfavorite(book) 74 | 75 | # Whether `user` has marked `book` as his favorite. 76 | user.favorited?(book) 77 | 78 | # Returns an Active Record relation of `user`'s `Favorite` records that have not been blocked. 79 | user.all_favorites 80 | 81 | # Returns an array of all unblocked favorited objects of `user`. This can be a collection of different object types, e.g.: `User`, `Book`. 82 | user.all_favorited 83 | 84 | # Returns an Active Record relation of `Favorite` records where the `favoritable_type` is `Book`. 85 | user.favorites_by_type('Book') 86 | 87 | # Returns an Active Record relation of all favorited objects of `user` where `favoritable_type` is 'Book'. 88 | user.favorited_by_type('Book') 89 | 90 | # Returns the exact same as `user.favorited_by_type('User')`. 91 | user.favorited_users 92 | 93 | # Whether `user` has been blocked by `book`. 94 | user.blocked_by?(book) 95 | 96 | # Returns an array of all favoritables that blocked `user`. 97 | user.blocked_by 98 | ``` 99 | 100 | ### `acts_as_favoritable` methods 101 | 102 | ```ruby 103 | # Returns all favoritors of a model that `acts_as_favoritable` 104 | book.favoritors 105 | 106 | # Returns an Active Record relation of records with type `User` following `book`. 107 | book.favoritors_by_type('User') 108 | 109 | # Returns the exact same as `book.favoritors_by_type('User')`. 110 | book.user_favoritors 111 | 112 | # Whether `book` has been favorited by `user`. 113 | book.favorited_by?(user) 114 | 115 | # Block a favoritor 116 | book.block(user) 117 | 118 | # Unblock a favoritor 119 | book.unblock(user) 120 | 121 | # Whether `book` has blocked `user` as favoritor. 122 | book.blocked?(user) 123 | 124 | # Returns an array of all blocked favoritors. 125 | book.blocked 126 | ``` 127 | 128 | ### `Favorite` model 129 | 130 | ```ruby 131 | # Returns an Active Record relation of all `Favorite` records where `blocked` is `false`. 132 | Favorite.unblocked 133 | 134 | # Returns an Active Record relation of all `Favorite` records where `blocked` is `true`. 135 | Favorite.blocked 136 | 137 | # Returns an Active Record relation of all favorites of `user`, including those who were blocked. 138 | Favorite.for_favoritor(user) 139 | 140 | # Returns an Active Record relation of all favoritors of `book`, including those who were blocked. 141 | Favorite.for_favoritable(book) 142 | ``` 143 | 144 | ### Scopes 145 | 146 | Using scopes with `acts_as_favoritor` enables you to Follow, Watch, Favorite, [...] between any of your models. This way you can separate distinct functionalities in your app between user states. For example: A user sees all his favorited books in a dashboard (`'favorite'`), but he only receives notifications for those, he is watching (`'watch'`). Just like YouTube or GitHub do it. Options are endless. You could also integrate a voting / star system similar to YouTube or GitHub 147 | 148 | By default all of your favorites are scoped to `'favorite'`. 149 | 150 | You can create new scopes on the fly. Every single method takes `scope`/`scopes` as an option which expects a symbol or an array of symbols containing your scopes. 151 | 152 | So lets see how this works: 153 | 154 | ```ruby 155 | user.favorite(book, scopes: [:favorite, :watching]) 156 | user.unfavorite(book, scope: :watching) 157 | second_user = User.find(2) 158 | user.favorite(second_user, scope: :follow) 159 | ``` 160 | 161 | That's simple! 162 | 163 | When you call a method which returns something while specifying multiple scopes, the method returns the results in a hash with the scopes as keys when scopes are given as an array: 164 | 165 | ```ruby 166 | user.favorited?(book, scopes: [:favorite, :watching]) # => { favorite: true, watching: false } 167 | user.favorited?(book, scopes: [:favorite]) # => { favorite: true } 168 | user.favorited?(book, scope: :favorite) # => true 169 | ``` 170 | 171 | `acts_as_favoritor` also provides some handy scopes for you to call on the `Favorite` model: 172 | 173 | ```ruby 174 | # Returns all `Favorite` records where `scope` is `my_scope` 175 | Favorite.send("#{my_scope}_list") 176 | 177 | ## Examples 178 | ### Returns all `Favorite` records where `scope` is `favorites` 179 | Favorite.favorite_list 180 | ### Returns all `Favorite` records where `scope` is `watching` 181 | Favorite.watching_list 182 | ``` 183 | 184 | ### Caching 185 | 186 | When you set the option `cache` in `config/initializers/acts_as_favoritor.rb` to true, you are able to cache the amount of favorites/favoritables an instance has regarding a scope. 187 | 188 | For that you need to add some database columns: 189 | 190 | *acts_as_favoritor* 191 | 192 | ```ruby 193 | add_column :users, :favoritor_score, :text 194 | add_column :users, :favoritor_total, :text 195 | ``` 196 | 197 | *acts_as_favoritable* 198 | 199 | ```ruby 200 | add_column :users, :favoritable_score, :text 201 | add_column :users, :favoritable_total, :text 202 | add_column :books, :favoritable_score, :text 203 | add_column :books, :favoritable_total, :text 204 | ``` 205 | 206 | Caches are stored as hashes with scopes as keys: 207 | 208 | ```ruby 209 | user.favoritor_score # => { favorite: 1 } 210 | user.favoritor_total # => { favorite: 1, watching: 1 } 211 | second_user.favoritable_score # => { follow: 1 } 212 | book.favoritable_score # => { favorite: 1 } 213 | ``` 214 | 215 | **Note:** Only scopes who have favorites are included. 216 | 217 | `acts_as_favoritor` makes it even simpler to access cached values: 218 | 219 | ```ruby 220 | user.favoritor_favorite_cache # => 1 221 | second_user.favoritable_follow_cache # => 1 222 | book.favoritable_favorite_cache # => 1 223 | ``` 224 | 225 | **Note:** These methods are available for every scope you are using. 226 | 227 | The total counts all favorites that were recorded, while the score factors in favorites that were removed. In most use cases the score is the most useful. 228 | 229 | ## Configuration 230 | 231 | You can configure acts_as_favoritor by passing a block to `configure`. This can be done in `config/initializers/acts_as_favoritor.rb`: 232 | 233 | ```ruby 234 | ActsAsFavoritor.configure do |config| 235 | config.default_scope = :follow 236 | end 237 | ``` 238 | 239 | **`default_scope`** Specify your default scope. Takes a string. Defaults to `:favorite`. Learn more about scopes [here](#scopes). 240 | 241 | **`cache`** Whether `acts_as_favoritor` uses caching or not. Takes a boolean. Defaults to `false`. Learn more about caching [here](#caching). 242 | 243 | ## Development 244 | 245 | To start development you first have to fork this repository and locally clone your fork. 246 | 247 | Install the projects dependencies by running: 248 | 249 | $ bundle install 250 | 251 | ### Testing 252 | 253 | Tests are written with RSpec and can be found in `/spec`. 254 | 255 | To run tests: 256 | 257 | $ bundle exec rspec 258 | 259 | To run RuboCop: 260 | 261 | $ bundle exec rubocop 262 | 263 | ## Contributing 264 | 265 | We warmly welcome everyone who is intersted in contributing. Please reference our [contributing guidelines](CONTRIBUTING.md) and our [Code of Conduct](CODE_OF_CONDUCT.md). 266 | 267 | ## Releases 268 | 269 | [Here](https://github.com/jonhue/acts_as_favoritor/releases) you can find details on all past releases. Unreleased breaking changes that are on the current `main` can be found [here](CHANGELOG.md). 270 | 271 | acts_as_favoritor follows Semantic Versioning 2.0 as defined at http://semver.org. Reference our [security policy](SECURITY.md). 272 | 273 | ### Publishing 274 | 275 | 1. Review breaking changes and deprecations in `CHANGELOG.md`. 276 | 1. Change the gem version in `lib/acts_as_favoritor/version.rb`. 277 | 1. Reset `CHANGELOG.md`. 278 | 1. Create a pull request to merge the changes into `main`. 279 | 1. After the pull request was merged, create a new release listing the breaking changes and commits on `main` since the last release. 280 | 2. The release workflow will publish the gem to RubyGems. 281 | -------------------------------------------------------------------------------- /spec/lib/acts_as_favoritor/favoritor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../../rails_helper' 4 | 5 | RSpec.describe ActsAsFavoritor::Favoritor do 6 | let(:sam) { create(:sam) } 7 | let(:jon) { create(:jon) } 8 | let(:beethoven) { create(:beethoven) } 9 | 10 | context 'without scopes' do 11 | before do 12 | sam.favorite(jon) 13 | sam.favorite(beethoven) 14 | jon.favorite(sam) 15 | beethoven.favorite(jon) 16 | end 17 | 18 | describe 'favorites' do 19 | it 'returns all favorite records of the given instance' do 20 | expect(sam.favorites) 21 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon), 22 | Favorite.find_by(favoritor: sam, favoritable: beethoven)] 23 | end 24 | end 25 | 26 | describe 'favorite' do 27 | # rubocop:disable RSpec/NestedGroups 28 | context 'when the given instance is the current object' do 29 | it 'returns nil' do 30 | expect(jon.favorite(jon)).to be_nil 31 | end 32 | 33 | it 'does not create a favorite record' do 34 | expect { jon.favorite(jon) }.to change { jon.favorites.size }.by(0) 35 | end 36 | end 37 | 38 | context 'when the given instance is not the current object' do 39 | it 'returns the new favorite record' do 40 | expect(beethoven.favorite(sam)) 41 | .to eq Favorite.find_by(favoritor: beethoven, favoritable: sam) 42 | end 43 | 44 | it 'creates a favorite record' do 45 | expect { beethoven.favorite(sam) } 46 | .to change { beethoven.favorites.size }.by(1) 47 | end 48 | end 49 | 50 | context 'when caching is enabled' do 51 | it 'updates the favoritor cache' 52 | 53 | it 'updates the favoritable cache' 54 | 55 | it 'does not update the cache when there was an early return' 56 | end 57 | # rubocop:enable RSpec/NestedGroups 58 | end 59 | 60 | describe 'unfavorite' do 61 | # rubocop:disable RSpec/NestedGroups 62 | context 'when the given instance has not been favorited' do 63 | it 'returns nil' do 64 | expect(beethoven.unfavorite(sam)).to be_nil 65 | end 66 | 67 | it 'does not delete a favorite record' do 68 | expect { beethoven.unfavorite(sam) } 69 | .to change { beethoven.favorites.size }.by(0) 70 | end 71 | end 72 | 73 | context 'when the given instance has been favorited' do 74 | it 'returns the deleted favorite record' do 75 | favorite_record = 76 | Favorite.find_by(favoritor: beethoven, favoritable: jon) 77 | 78 | expect(beethoven.unfavorite(jon)) 79 | .to eq favorite_record 80 | end 81 | 82 | it 'deletes a favorite record' do 83 | expect { beethoven.unfavorite(jon) } 84 | .to change { beethoven.favorites.size }.by(-1) 85 | end 86 | end 87 | 88 | context 'when caching is enabled' do 89 | it 'updates the favoritor cache' 90 | 91 | it 'updates the favoritable cache' 92 | 93 | it 'does not update the cache when there was an early return' 94 | end 95 | # rubocop:enable RSpec/NestedGroups 96 | end 97 | 98 | describe 'favorited?' do 99 | it 'returns true if the instance favorited the given record' do 100 | expect(beethoven.favorited?(jon)).to be true 101 | end 102 | 103 | it 'returns false if the instance did not favorite the given record' do 104 | expect(jon.favorited?(beethoven)).to be false 105 | end 106 | end 107 | 108 | describe 'all_favorites' do 109 | before { beethoven.block(sam) } 110 | 111 | it 'returns all unblocked favorite records of the given instance' do 112 | expect(sam.all_favorites).not_to be_a Array 113 | 114 | expect(sam.all_favorites) 115 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon)] 116 | end 117 | end 118 | 119 | describe 'all_favorited' do 120 | before { beethoven.block(sam) } 121 | 122 | it 'returns all unblocked favorites of the given instance' do 123 | expect(sam.all_favorited).to eq [jon] 124 | end 125 | end 126 | 127 | describe 'favorites_by_type' do 128 | it 'all unblocked favorite records of the current instance where ' \ 129 | 'the favoritable is of the given type' do 130 | expect(sam.favorites_by_type('User')).not_to be_a Array 131 | 132 | expect(sam.favorites_by_type('User')) 133 | .to eq [Favorite.find_by(favoritor: sam, favoritable: jon)] 134 | expect(sam.favorites_by_type('Composer')) 135 | .to eq [Favorite.find_by(favoritor: sam, favoritable: beethoven)] 136 | end 137 | end 138 | 139 | describe 'favorited_by_type' do 140 | it 'all unblocked favorites of the current instance where ' \ 141 | 'the favoritable is of the given type' do 142 | expect(sam.favorited_by_type('User')).not_to be_a Array 143 | 144 | expect(sam.favorited_by_type('User')).to eq [jon] 145 | expect(sam.favorited_by_type('Composer')).to eq [beethoven] 146 | end 147 | end 148 | 149 | describe 'blocked_by?' do 150 | it 'returns true if the given instance blocked this object' do 151 | jon.block(beethoven) 152 | 153 | expect(beethoven.blocked_by?(jon)).to be true 154 | end 155 | 156 | it 'returns false if the given instance did not block this object' do 157 | expect(jon.blocked_by?(beethoven)).to be false 158 | end 159 | end 160 | 161 | describe 'blocked_by' do 162 | before { jon.block(beethoven) } 163 | 164 | it 'returns blocked favoritors' do 165 | expect(beethoven.blocked_by).to eq [jon] 166 | end 167 | end 168 | 169 | context 'with magic methods' do 170 | it 'responds to magic methods' do 171 | expect(jon).to respond_to(:favorited_users) 172 | end 173 | 174 | it 'still raises a NoMethodError' do 175 | expect { jon.foobar }.to raise_error(NoMethodError) 176 | end 177 | 178 | it 'favorited_* returns favorites of the given type' do 179 | expect(jon.favorited_users).to eq [sam] 180 | end 181 | end 182 | end 183 | 184 | context 'with scopes' do 185 | before do 186 | sam.favorite(jon, scopes: [:friend]) 187 | sam.favorite(beethoven, scopes: [:favorite, :friend]) 188 | jon.favorite(sam, scopes: [:friend]) 189 | beethoven.favorite(jon, scopes: [:favorite]) 190 | end 191 | 192 | describe 'favorite' do 193 | it 'creates a favorite record with the given scopes' do 194 | expect { beethoven.favorite(jon, scope: :friend) } 195 | .to change { beethoven.all_favorites(scope: :friend).size }.by(1) 196 | .and change { beethoven.all_favorites(scope: :favorite).size } 197 | .by(0) 198 | end 199 | end 200 | 201 | describe 'unfavorite' do 202 | it 'deletes favorite records with the given scopes' do 203 | expect { sam.unfavorite(beethoven, scope: :friend) } 204 | .to change { sam.all_favorites(scope: :friend).size }.by(-1) 205 | .and change { sam.all_favorites(scope: :favorite).size }.by(0) 206 | end 207 | end 208 | 209 | describe 'favorited?' do 210 | it 'returns true if the instance favorited the given record' do 211 | expect(beethoven.favorited?(jon, scope: :favorite)).to be true 212 | end 213 | 214 | it 'returns false if the instance did not favorite the given record' do 215 | expect(beethoven.favorited?(jon, scope: :friend)).to be false 216 | end 217 | end 218 | 219 | describe 'all_favorites' do 220 | it 'returns all unblocked favorite records of the given instance' do 221 | expect(sam.all_favorites(scope: :favorite)) 222 | .to eq [Favorite.find_by(favoritor: sam, favoritable: beethoven)] 223 | end 224 | end 225 | 226 | describe 'all_favorited' do 227 | it 'returns all unblocked favorites of the given instance' do 228 | expect(sam.all_favorited(scope: :favorite)).to eq [beethoven] 229 | end 230 | end 231 | 232 | describe 'favorites_by_type' do 233 | it 'all unblocked favorite records of the current instance ' \ 234 | 'where the favoritable is of the given type' do 235 | expect(sam.favorites_by_type('User', scope: :favorite)).to eq [] 236 | expect(sam.favorites_by_type('Composer', scopes: [:favorite, :friend])) 237 | .to eq( 238 | favorite: [Favorite.find_by(favoritor: sam, favoritable: beethoven, 239 | scope: :favorite)], 240 | friend: [Favorite.find_by(favoritor: sam, favoritable: beethoven, 241 | scope: :friend)] 242 | ) 243 | end 244 | end 245 | 246 | describe 'favorited_by_type' do 247 | it 'all unblocked favorites of the current instance ' \ 248 | 'where the favoritable is of the given type' do 249 | expect(sam.favorited_by_type('User', scope: :favorite)).to eq [] 250 | expect(sam.favorited_by_type('Composer', scopes: [:favorite, :friend])) 251 | .to eq favorite: [beethoven], friend: [beethoven] 252 | end 253 | end 254 | 255 | describe 'blocked_by?' do 256 | it 'returns true if the given instance blocked this object' do 257 | jon.block(beethoven, scope: :favorite) 258 | 259 | expect(beethoven.blocked_by?(jon, scope: :favorite)).to be true 260 | end 261 | 262 | it 'returns false if the given instance did not block this object' do 263 | jon.block(beethoven, scope: :friend) 264 | 265 | expect(beethoven.blocked_by?(jon, scope: :favorite)).to be false 266 | end 267 | end 268 | 269 | describe 'blocked_by' do 270 | before do 271 | jon.block(beethoven, scope: :friend) 272 | jon.block(sam, scope: :favorite) 273 | end 274 | 275 | it 'returns blocked favoritors' do 276 | expect(beethoven.blocked_by(scope: :friend)).to eq [jon] 277 | end 278 | end 279 | end 280 | end 281 | --------------------------------------------------------------------------------