├── app ├── helpers │ └── .keep ├── jobs │ ├── .keep │ └── counter │ │ └── reconciliation_job.rb ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ └── counter │ │ │ ├── reset.rb │ │ │ ├── hooks.rb │ │ │ ├── summable.rb │ │ │ ├── calculated.rb │ │ │ ├── Xhierarchical.rb │ │ │ ├── definable.rb │ │ │ ├── conditional.rb │ │ │ ├── recalculatable.rb │ │ │ ├── changable.rb │ │ │ ├── increment.rb │ │ │ ├── sidekiq_reconciliation.rb │ │ │ └── verifyable.rb │ └── counter │ │ └── value.rb ├── views │ └── .keep ├── controllers │ ├── .keep │ └── counters_controller.rb └── assets │ ├── images │ └── counter │ │ └── .keep │ ├── config │ └── counter_manifest.js │ └── stylesheets │ └── counter │ └── .keep ├── test ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── orders_counter.rb │ │ │ ├── product_counter.rb │ │ │ ├── visits_counter.rb │ │ │ ├── application_record.rb │ │ │ ├── global_order_counter.rb │ │ │ ├── cigarette_counter.rb │ │ │ ├── product_discounts_counter.rb │ │ │ ├── returned_order_counter.rb │ │ │ ├── conversion_rate_counter.rb │ │ │ ├── order_revenue_counter.rb │ │ │ ├── special_product.rb │ │ │ ├── subscription.rb │ │ │ ├── order.rb │ │ │ ├── coupon.rb │ │ │ ├── premium_product_counter.rb │ │ │ ├── user.rb │ │ │ └── product.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── views │ │ │ └── layouts │ │ │ │ ├── mailer.text.erb │ │ │ │ ├── mailer.html.erb │ │ │ │ └── application.html.erb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ ├── jobs │ │ │ └── application_job.rb │ │ └── javascript │ │ │ └── packs │ │ │ └── application.js │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── environment.rb │ │ ├── routes.rb │ │ ├── initializers │ │ │ ├── mime_types.rb │ │ │ ├── application_controller_renderer.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── wrap_parameters.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ ├── db │ │ ├── migrate │ │ │ ├── 20210729221240_create_users.rb │ │ │ ├── 20250828185102_add_grumpy_to_users.rb │ │ │ ├── 20250729154441_create_coupons.rb │ │ │ ├── 20210729221340_create_products.rb │ │ │ ├── 20250730141956_create_subscriptions.rb │ │ │ ├── 20210729221419_create_orders.rb │ │ │ ├── 20230710225537_add_unique_index_to_counter_values.counter.rb │ │ │ └── 20230710225535_create_counter_values.counter.rb │ │ └── schema.rb │ └── Rakefile ├── integration │ ├── counters_test.rb │ ├── sum_test.rb │ ├── reset_test.rb │ ├── calculated_test.rb │ ├── hooks_test.rb │ ├── verify_test.rb │ ├── conditional_test.rb │ ├── recalc_test.rb │ ├── increment_test.rb │ ├── change_test.rb │ └── definition_test.rb ├── counter_test.rb ├── controllers │ └── counters_controller_test.rb └── test_helper.rb ├── .tool-versions ├── lib ├── counter │ ├── error.rb │ ├── version.rb │ ├── railtie.rb │ ├── engine.rb │ ├── any.rb │ ├── conditions.rb │ ├── rspec │ │ └── matchers.rb │ ├── integration │ │ ├── countable.rb │ │ └── counters.rb │ └── definition.rb ├── tasks │ └── counter_tasks.rake └── counter.rb ├── docs └── data_model.png ├── config └── routes.rb ├── bin ├── test └── rails ├── .gitignore ├── db └── migrate │ ├── 20210731224504_add_unique_index_to_counter_values.rb │ └── 20210705154113_create_counter_values.rb ├── Gemfile ├── Rakefile ├── .vscode └── settings.json ├── counter.gemspec ├── .devcontainer ├── Dockerfile └── devcontainer.json ├── MIT-LICENSE ├── .github └── workflows │ └── ruby.yml ├── Gemfile.lock └── README.md /app/helpers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/jobs/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/views/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 3.4.4 2 | -------------------------------------------------------------------------------- /app/assets/images/counter/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/config/counter_manifest.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/stylesheets/counter/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /lib/counter/error.rb: -------------------------------------------------------------------------------- 1 | class Counter::Error < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /lib/counter/version.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | VERSION = "0.1.6" 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /docs/data_model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/HEAD/docs/data_model.png -------------------------------------------------------------------------------- /lib/counter/railtie.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | class Railtie < ::Rails::Railtie 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/orders_counter.rb: -------------------------------------------------------------------------------- 1 | class OrdersCounter < Counter::Definition 2 | count :orders 3 | end 4 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :counters, only: [:update, :destroy] 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/models/product_counter.rb: -------------------------------------------------------------------------------- 1 | class ProductCounter < Counter::Definition 2 | count :products 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/visits_counter.rb: -------------------------------------------------------------------------------- 1 | class VisitsCounter < Counter::Definition 2 | as "visits_counter" 3 | end 4 | -------------------------------------------------------------------------------- /test/integration/counters_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CountersTest < ActiveSupport::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /lib/counter/engine.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Counter 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/tasks/counter_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :counter do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/global_order_counter.rb: -------------------------------------------------------------------------------- 1 | class GlobalOrderCounter < Counter::Definition 2 | global 3 | as "total_orders" 4 | end 5 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/cigarette_counter.rb: -------------------------------------------------------------------------------- 1 | class CigaretteCounter < Counter::Definition 2 | calculated_value ->(user) { user.grumpy? ? 212 : 4 } 3 | end 4 | -------------------------------------------------------------------------------- /lib/counter/any.rb: -------------------------------------------------------------------------------- 1 | # Simple class to represent any value in the filters 2 | require "singleton" 3 | 4 | class Counter::Any 5 | include Singleton 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/app/models/product_discounts_counter.rb: -------------------------------------------------------------------------------- 1 | class ProductDiscountsCounter < Counter::Definition 2 | count :coupons, as: :product_discounts 3 | sum :amount 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /test/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /test/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /test/counter_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CounterTest < ActiveSupport::TestCase 4 | test "it has a version number" do 5 | assert Counter::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /test/dummy/app/models/returned_order_counter.rb: -------------------------------------------------------------------------------- 1 | class ReturnedOrderCounter < Counter::Definition 2 | calculated_value ->(order) { 500 }, association: :orders 3 | record_name :users_returned_orders 4 | end 5 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20210729221240_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :users do |t| 4 | t.timestamps 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20250828185102_add_grumpy_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddGrumpyToUsers < ActiveRecord::Migration[8.0] 2 | def change 3 | add_column :users, :grumpy, :boolean, default: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /test/dummy/db/*.sqlite3 7 | /test/dummy/db/*.sqlite3-* 8 | /test/dummy/log/*.log 9 | /test/dummy/storage/ 10 | /test/dummy/tmp/ 11 | .byebug_history 12 | -------------------------------------------------------------------------------- /app/models/concerns/counter/reset.rb: -------------------------------------------------------------------------------- 1 | module Counter::Reset 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | def reset! 6 | with_lock do 7 | update! value: 0 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /test/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /test/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /test/dummy/app/models/conversion_rate_counter.rb: -------------------------------------------------------------------------------- 1 | class ConversionRateCounter < Counter::Definition 2 | count nil, as: "conversion_rate" 3 | 4 | calculated_from VisitsCounter, OrdersCounter do |visits, orders| 5 | (orders.value.to_f / visits.value) * 100 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210731224504_add_unique_index_to_counter_values.rb: -------------------------------------------------------------------------------- 1 | class AddUniqueIndexToCounterValues < ActiveRecord::Migration[6.1] 2 | def change 3 | add_index :counter_values, [:parent_type, :parent_id, :name], 4 | unique: true, name: "unique_counter_values" 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20250729154441_create_coupons.rb: -------------------------------------------------------------------------------- 1 | class CreateCoupons < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :coupons do |t| 4 | t.references :discountable, polymorphic: true, null: false 5 | t.integer :amount 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [ 5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 6 | ] 7 | -------------------------------------------------------------------------------- /lib/counter.rb: -------------------------------------------------------------------------------- 1 | require "counter/version" 2 | require "counter/engine" 3 | require "counter/railtie" 4 | require "counter/integration/counters" 5 | require "counter/integration/countable" 6 | require "counter/any" 7 | require "counter/conditions" 8 | require "counter/error" 9 | 10 | module Counter 11 | # Your code goes here... 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20210729221340_create_products.rb: -------------------------------------------------------------------------------- 1 | class CreateProducts < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :products do |t| 4 | t.references :user, null: false, foreign_key: true 5 | t.string :name 6 | t.integer :price 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20250730141956_create_subscriptions.rb: -------------------------------------------------------------------------------- 1 | class CreateSubscriptions < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :subscriptions do |t| 4 | t.references :user, null: false, foreign_key: true 5 | t.string :name 6 | t.integer :price 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/integration/sum_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class SumTest < ActiveSupport::TestCase 4 | test "can sum a column value" do 5 | u = User.create! 6 | product = u.products.create! 7 | 3.times { u.orders.create! product: product, price: 10 } 8 | counter = product.order_revenue 9 | assert_equal 30, counter.value 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20210729221419_create_orders.rb: -------------------------------------------------------------------------------- 1 | class CreateOrders < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :orders do |t| 4 | t.references :user, null: false, foreign_key: true 5 | t.references :product, null: false, foreign_key: true 6 | t.integer :price 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20230710225537_add_unique_index_to_counter_values.counter.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from counter (originally 20210731224504) 2 | class AddUniqueIndexToCounterValues < ActiveRecord::Migration[6.1] 3 | def change 4 | add_index :counter_values, [:parent_type, :parent_id, :name], 5 | unique: true, name: "unique_counter_values" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20210705154113_create_counter_values.rb: -------------------------------------------------------------------------------- 1 | class CreateCounterValues < ActiveRecord::Migration[6.1] 2 | def change 3 | create_table :counter_values do |t| 4 | t.string :name, index: true 5 | t.decimal :value, default: 0.0, null: false 6 | t.references :parent, polymorphic: true 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/integration/reset_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ResetTest < ActiveSupport::TestCase 4 | test "resets the counter " do 5 | u = User.create 6 | u.products.create! 7 | counter = u.counters.find_counter ProductCounter 8 | assert_equal 1, counter.reload.value 9 | counter.reset! 10 | assert_equal 0, counter.reload.value 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/app/models/order_revenue_counter.rb: -------------------------------------------------------------------------------- 1 | class OrderRevenueCounter < Counter::Definition 2 | count :orders, as: :order_revenue 3 | sum :price 4 | 5 | after_change :send_congratulations_email 6 | 7 | def send_congratulations_email counter, from, to 8 | return unless from < 1000 && to >= 1000 9 | puts "Congratulations! You've made #{to.to_i} dollars!" 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/counter/conditions.rb: -------------------------------------------------------------------------------- 1 | class Counter::Conditions 2 | attr_accessor :increment_conditions, :decrement_conditions 3 | 4 | def initialize 5 | @increment_conditions = [] 6 | @decrement_conditions = [] 7 | end 8 | 9 | def increment_if block 10 | increment_conditions << block 11 | end 12 | 13 | def decrement_if block 14 | decrement_conditions << block 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag 'application', media: 'all' %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /test/dummy/app/models/special_product.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: products 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null, indexed 7 | # name :string 8 | # price :integer 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class SpecialProduct < Product 13 | end 14 | -------------------------------------------------------------------------------- /app/jobs/counter/reconciliation_job.rb: -------------------------------------------------------------------------------- 1 | class Counter::ReconciliationJob 2 | # include Sidekiq::Worker 3 | 4 | def perform counter_id 5 | counter = Counter::Value.find(counter_id) 6 | changes = Counter::Change.where(counter: counter).pending 7 | changes.with_lock do 8 | counter.increment! changes.sum(increment) 9 | changes.update_all processed: Time.now 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in counter.gemspec. 5 | gemspec 6 | 7 | group :development do 8 | gem "sqlite3" 9 | gem "annotate" 10 | gem "standard" 11 | end 12 | 13 | group :test do 14 | gem "minitest-reporters" 15 | end 16 | 17 | # To use a debugger 18 | gem "debug", group: [:development, :test] 19 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | 10 | require "rake/testtask" 11 | 12 | Rake::TestTask.new(:test) do |t| 13 | t.libs << "test" 14 | t.pattern = "test/**/*_test.rb" 15 | t.verbose = false 16 | end 17 | 18 | task default: :test 19 | -------------------------------------------------------------------------------- /test/integration/calculated_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CalculatedTest < ActiveSupport::TestCase 4 | test "calculated counters are kept up-to-date" do 5 | u = User.create! 6 | product = Product.create! user: u, price: 1000 7 | u.visits_counter.increment! by: 100 8 | 2.times { u.orders.create! product: product, price: 100 } 9 | assert_equal 2, u.conversion_rate.value 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.insertFinalNewline": true, 3 | "files.trimFinalNewlines": true, 4 | "files.trimTrailingWhitespace": true, 5 | "editor.formatOnSave": true, 6 | "editor.formatOnPaste": true, 7 | "ruby.format": "standard", 8 | "ruby.useBundler": true, 9 | "ruby.useLanguageServer": true, 10 | "ruby.lint": { 11 | "standard": { 12 | "useBundler": true 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /test/dummy/db/migrate/20230710225535_create_counter_values.counter.rb: -------------------------------------------------------------------------------- 1 | # This migration comes from counter (originally 20210705154113) 2 | class CreateCounterValues < ActiveRecord::Migration[6.1] 3 | def change 4 | create_table :counter_values do |t| 5 | t.string :name, index: true 6 | t.decimal :value, default: 0.0, null: false 7 | t.references :parent, polymorphic: true 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /test/dummy/app/models/subscription.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: subscriptions 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null, indexed 7 | # name :string 8 | # price :integer 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class Subscription < ApplicationRecord 13 | has_many :coupons, as: :discountable 14 | end 15 | -------------------------------------------------------------------------------- /test/dummy/app/models/order.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: orders 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null, indexed 7 | # product_id :integer not null, indexed 8 | # price :integer 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class Order < ApplicationRecord 13 | belongs_to :user 14 | belongs_to :product 15 | end 16 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /app/models/concerns/counter/hooks.rb: -------------------------------------------------------------------------------- 1 | # Allow hooks to be defined on the counter 2 | module Counter::Hooks 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | after_save :call_counter_hooks 7 | 8 | def call_counter_hooks 9 | return unless previous_changes["value"] 10 | 11 | from, to = previous_changes["value"] 12 | definition.counter_hooks.each do |hook| 13 | definition.send(hook, self, from, to) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /app/models/concerns/counter/summable.rb: -------------------------------------------------------------------------------- 1 | # count_using :price 2 | # count_using ->{ revenue * priority } 3 | # This lets you keep running totals of revenue etc rather than just a count of the orders 4 | module Counter::Summable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | # Replace Increment#increment_from_item 9 | def increment_from_item item 10 | return item.send definition.column_to_count if definition.sum? 11 | 12 | 1 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/dummy/app/models/coupon.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: coupons 4 | # 5 | # id :integer not null, primary key 6 | # discountable_id :integer not null, indexed 7 | # discountable_type :string not null, indexed 8 | # amount :integer not null 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class Coupon < ApplicationRecord 13 | belongs_to :discountable, polymorphic: true 14 | end 15 | -------------------------------------------------------------------------------- /app/controllers/counters_controller.rb: -------------------------------------------------------------------------------- 1 | # A stupid little controller showing how easy you can build generic "counter" functionality 2 | # when they're represented as a model 3 | class CountersController < ApplicationController 4 | # Reset a counter to 0 5 | def destroy 6 | Counter::Value.find(params[:id]).reset! 7 | 8 | redirect_back fallback_location: "/" 9 | end 10 | 11 | # Recalculate a counter 12 | def update 13 | Counter::Value.find(params[:id]).recalc! 14 | 15 | redirect_back fallback_location: "/" 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | # Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path('..', __dir__) 6 | ENGINE_PATH = File.expand_path('../lib/counter/engine', __dir__) 7 | APP_PATH = File.expand_path('../test/dummy/config/application', __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /test/dummy/app/models/premium_product_counter.rb: -------------------------------------------------------------------------------- 1 | class PremiumProductCounter < Counter::Definition 2 | count :premium_products 3 | 4 | on :create do 5 | increment_if ->(product) { product.premium? } 6 | end 7 | 8 | on :delete do 9 | decrement_if ->(product) { product.premium? } 10 | end 11 | 12 | on :update do 13 | increment_if ->(product) { product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 } } 14 | decrement_if ->(product) { product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 } } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/controllers/counters_controller_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CountersControllerTest < ActionDispatch::IntegrationTest 4 | # test "should be reset" do 5 | # counter = Counter::Value.create! value: 50 6 | # delete counter_path(counter) 7 | # assert_response :redirect 8 | # assert_equal(0, counter.reload.value) 9 | # end 10 | 11 | # test "should be recalculated" do 12 | # skip "Need to figure out how to setup reloading" 13 | # counter = Counter::Value.create! value: 50 14 | # patch counter_path(counter) 15 | # assert_response :redirect 16 | # assert_equal(0, counter.reload.value) 17 | # end 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: users 4 | # 5 | # id :integer not null, primary key 6 | # created_at :datetime not null 7 | # updated_at :datetime not null 8 | # 9 | class User < ApplicationRecord 10 | include Counter::Counters 11 | 12 | has_many :products 13 | has_many :premium_products, -> { premium }, class_name: "Product" 14 | has_many :orders 15 | has_many :subscriptions 16 | 17 | counter ProductCounter, PremiumProductCounter, OrdersCounter, VisitsCounter 18 | counter ConversionRateCounter 19 | counter CigaretteCounter 20 | counter ReturnedOrderCounter 21 | 22 | def grumpy! = update!(grumpy: true) 23 | end 24 | -------------------------------------------------------------------------------- /test/integration/hooks_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class HooksTest < ActiveSupport::TestCase 4 | test "allows hooks to be defined on the counter" do 5 | u = User.create! 6 | product = u.products.create! 7 | assert_output "Congratulations! You've made 1000 dollars!\n" do 8 | u.orders.create! product: product, price: 1000 9 | end 10 | product.order_revenue.reset! 11 | u.orders.create! product: product, price: 500 12 | assert_output "Congratulations! You've made 1000 dollars!\n" do 13 | u.orders.create! product: product, price: 500 14 | end 15 | 16 | assert_output "" do 17 | u.orders.create! product: product, price: 500 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /app/models/concerns/counter/calculated.rb: -------------------------------------------------------------------------------- 1 | module Counter::Calculated 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | def calculate! 6 | new_value = calculate 7 | update! value: new_value unless new_value.nil? 8 | end 9 | 10 | def calculate 11 | counters = counters_for_calculation 12 | # If any of the counters are missing, we can't calculate 13 | return if counters.any?(&:nil?) 14 | 15 | definition.calculated_from.call(*counters) 16 | end 17 | 18 | def counters_for_calculation 19 | # Fetch the dependant counters 20 | definition.dependent_counters.map do |counter| 21 | parent.counters.find_counter(counter) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/app/models/product.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: products 4 | # 5 | # id :integer not null, primary key 6 | # user_id :integer not null, indexed 7 | # name :string 8 | # price :integer 9 | # created_at :datetime not null 10 | # updated_at :datetime not null 11 | # 12 | class Product < ApplicationRecord 13 | include Counter::Counters 14 | include Counter::Changable 15 | 16 | belongs_to :user 17 | has_many :orders 18 | has_many :coupons, as: :discountable 19 | 20 | scope :premium, -> { where("price >= 1000") } 21 | counter OrderRevenueCounter, ProductDiscountsCounter 22 | 23 | def premium? 24 | (price || 0) >= 1000 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /test/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 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 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # Configure Rails Environment 2 | ENV["RAILS_ENV"] = "test" 3 | 4 | require_relative "../test/dummy/config/environment" 5 | ActiveRecord::Migrator.migrations_paths = [File.expand_path("../test/dummy/db/migrate", __dir__)] 6 | require "rails/test_help" 7 | 8 | require "minitest/reporters" 9 | Minitest::Reporters.use! 10 | 11 | # Load fixtures from the engine 12 | if ActiveSupport::TestCase.respond_to?(:fixture_path=) 13 | ActiveSupport::TestCase.fixture_path = File.expand_path("fixtures", __dir__) 14 | ActionDispatch::IntegrationTest.fixture_path = ActiveSupport::TestCase.fixture_path 15 | ActiveSupport::TestCase.file_fixture_path = ActiveSupport::TestCase.fixture_path + "/files" 16 | ActiveSupport::TestCase.fixtures :all 17 | end 18 | -------------------------------------------------------------------------------- /lib/counter/rspec/matchers.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | module RSpecMatchers 3 | def increment_counter_for(...) 4 | IncrementCounterFor.new(...) 5 | end 6 | 7 | def decrement_counter_for(...) 8 | DecrementCounterFor.new(...) 9 | end 10 | 11 | class Base < RSpec::Matchers::BuiltIn::Change 12 | def initialize(counter_class, parent) 13 | super { parent.counters.find_or_create_counter!(counter_class).value } 14 | end 15 | end 16 | 17 | class IncrementCounterFor < Base 18 | def matches?(...) 19 | by(1).matches?(...) 20 | end 21 | end 22 | 23 | class DecrementCounterFor < Base 24 | def matches?(...) 25 | by(-1).matches?(...) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | require "counter" 9 | 10 | module Dummy 11 | class Application < Rails::Application 12 | config.load_defaults Rails::VERSION::STRING.to_f 13 | 14 | # Configuration for the application, engines, and railties goes here. 15 | # 16 | # These settings can be overridden in specific environments using the files 17 | # in config/environments, which are processed later. 18 | # 19 | # config.time_zone = "Central Time (US & Canada)" 20 | # config.eager_load_paths << Rails.root.join("extras") 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /test/dummy/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. JavaScript code in this file should be added after the last require_* statement. 9 | // 10 | // Read Sprockets README (https://github.com/rails/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require rails-ujs 14 | //= require activestorage 15 | //= require_tree . 16 | -------------------------------------------------------------------------------- /test/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/models/concerns/counter/Xhierarchical.rb: -------------------------------------------------------------------------------- 1 | module Counter::Xhierarchical 2 | extend ActiveSupport::Concern 3 | 4 | ########################################################## Support hierarchy of counters 5 | # e.g. a open counter for an email > a newsletter > a drip_campaign > a site 6 | def counters_to_update 7 | [self] + dependant_counters.flat_map { |c| c.counters_to_update } 8 | end 9 | 10 | # Override this to add other counters 11 | def dependant_counters 12 | [] 13 | end 14 | 15 | def perform_update! increment 16 | Counter.increment_all! counters_to_update, by: increment 17 | end 18 | 19 | # In a single SQL transaction, increment the counters 20 | def self.increment_all! counters, by: 1 21 | Counter.lock.where(id: counters).update_all! "value = value + ?, updated_at: NOW()", by 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /counter.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/counter/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "counterwise" 5 | spec.version = Counter::VERSION 6 | spec.authors = ["Jamie Lawrence"] 7 | spec.email = ["jamie@ideasasylum.com"] 8 | spec.homepage = "https://github.com/podia/counter" 9 | spec.summary = "Counters and the counting counters that count them" 10 | spec.description = "Counting and aggregation library for Rails." 11 | spec.license = "MIT" 12 | 13 | spec.required_ruby_version = ">= 3.4.0" 14 | 15 | spec.metadata["homepage_uri"] = spec.homepage 16 | spec.metadata["source_code_uri"] = "https://github.com/podia/counter" 17 | spec.metadata["changelog_uri"] = "https://github.com/podia/counter/CHANGELOG.md" 18 | 19 | spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 20 | 21 | spec.add_dependency "rails", ">= 8" 22 | end 23 | -------------------------------------------------------------------------------- /app/models/concerns/counter/definable.rb: -------------------------------------------------------------------------------- 1 | # Fetch the definition for a counter 2 | # counter.definition # => Counter::Definition 3 | module Counter::Definable 4 | extend ActiveSupport::Concern 5 | 6 | included do 7 | def definition= definition 8 | @definition = definition 9 | end 10 | 11 | # Fetch the definition for this counter 12 | def definition 13 | @definition ||= begin 14 | if parent.nil? 15 | # We don't have a parent, so we're a global counter 16 | Counter::Definition.find_definition name 17 | else 18 | parent.class.ancestors.find do |ancestor| 19 | return nil if ancestor == ApplicationRecord 20 | next unless ancestor.respond_to?(:counter_configs) 21 | config = ancestor.counter_configs.find { |c| c.record_name == name } 22 | return config if config 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/models/concerns/counter/conditional.rb: -------------------------------------------------------------------------------- 1 | module Counter::Conditional 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | def increment? item, on 6 | accept_item? item, on, increment: true 7 | end 8 | 9 | def decrement? item, on 10 | accept_item? item, on, increment: false 11 | end 12 | 13 | def accept_item? item, on, increment: true 14 | return false if definition.calculated_value? 15 | return true unless definition.conditional? 16 | 17 | conditions = definition.conditions[on] 18 | return true unless conditions 19 | 20 | conditions.any? do |conditions| 21 | if increment 22 | conditions.increment_conditions.any? do |condition| 23 | condition.call(item) 24 | end 25 | else 26 | conditions.decrement_conditions.any? do |condition| 27 | condition.call(item) 28 | end 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/dummy/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 https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /app/models/concerns/counter/recalculatable.rb: -------------------------------------------------------------------------------- 1 | module Counter::Recalculatable 2 | extend ActiveSupport::Concern 3 | 4 | def recalc! 5 | if definition.calculated_value? 6 | recalculate_with_value! 7 | elsif definition.calculated? 8 | calculate! 9 | elsif definition.manual? 10 | raise Counter::Error.new("Can't recalculate a manual counter") 11 | else 12 | with_lock do 13 | new_value = definition.sum? ? sum_by_sql : count_by_sql 14 | update! value: new_value 15 | end 16 | end 17 | end 18 | 19 | def count_by_sql 20 | recalc_scope.count 21 | end 22 | 23 | def sum_by_sql 24 | recalc_scope.sum(definition.column_to_count) 25 | end 26 | 27 | # use this scope when recalculating the value 28 | def recalc_scope 29 | parent.association(definition.association_name).scope 30 | end 31 | 32 | private 33 | 34 | def recalculate_with_value! 35 | with_lock do 36 | update!(value: definition.calculated_value.call(parent)) 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Ruby version: 3, 3.0, 2, 2.7, 2.6 4 | ARG VARIANT="3.0" 5 | FROM mcr.microsoft.com/vscode/devcontainers/ruby:0-${VARIANT} 6 | 7 | # [Choice] Node.js version: none, lts/*, 16, 14, 12, 10 8 | ARG NODE_VERSION="none" 9 | RUN if [ "${NODE_VERSION}" != "none" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi 10 | 11 | # [Optional] Uncomment this section to install additional OS packages. 12 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 13 | # && apt-get -y install --no-install-recommends 14 | 15 | # [Optional] Uncomment this line to install additional gems. 16 | # RUN gem install 17 | 18 | # [Optional] Uncomment this line to install global node packages. 19 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1 -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 Jamie Lawrence 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /test/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.191.1/containers/ruby 3 | { 4 | "name": "Ruby", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | "args": { 8 | // Update 'VARIANT' to pick a Ruby version: 3, 3.0, 2, 2.7, 2.6 9 | "VARIANT": "2.7", 10 | // Options 11 | "NODE_VERSION": "lts/*" 12 | } 13 | }, 14 | 15 | // Set *default* container specific settings.json values on container create. 16 | "settings": {}, 17 | 18 | // Add the IDs of extensions you want installed when the container is created. 19 | "extensions": [ 20 | "rebornix.Ruby", 21 | "rebornix.ruby", 22 | "dracula-theme.theme-dracula", 23 | "eamodio.gitlens" 24 | ], 25 | 26 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 27 | // "forwardPorts": [], 28 | 29 | // Use 'postCreateCommand' to run commands after the container is created. 30 | "postCreateCommand": "bundle install", 31 | 32 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 33 | "remoteUser": "vscode" 34 | 35 | } 36 | -------------------------------------------------------------------------------- /test/integration/verify_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class VerifyTest < ActiveSupport::TestCase 4 | test "verifies if the counter is correct" do 5 | u = User.create 6 | u.products.create! 7 | counter = u.products_counter 8 | assert_equal 1, counter.reload.value 9 | assert counter.correct? 10 | assert_equal true, counter.correct! 11 | counter.reset! 12 | assert !counter.correct? 13 | assert_equal false, counter.correct! 14 | assert 1, counter.value 15 | end 16 | 17 | test "verifies a calculated counter" do 18 | u = User.create 19 | u.orders_counter.increment! by: 2 20 | u.visits_counter.increment! by: 100 21 | u.conversion_rate.update! value: 0.5 22 | assert !u.conversion_rate.correct? 23 | end 24 | 25 | test "verify return correct and current values" do 26 | u = User.create 27 | u.products.create! 28 | u.products_counter.increment! by: 2 29 | assert [1, 3], u.products_counter.verify 30 | end 31 | 32 | test "sample_and_verify" do 33 | u = User.create! 34 | u.products.create! 35 | u.products_counter.increment! by: 2 36 | assert_equal 1, Counter::Value.sample_and_verify(samples: 1, verbose: false, on_error: :correct) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: Ruby 9 | 10 | on: 11 | push: 12 | branches: [ "main" ] 13 | pull_request: 14 | branches: [ "main" ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | ruby-version: ['3.4.5'] 26 | 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Set up Ruby 30 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 31 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby-version }} 35 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 36 | - name: Run tests 37 | run: bundle exec rake test 38 | -------------------------------------------------------------------------------- /test/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /test/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | 19 | # If you are using UJS then enable automatic nonce generation 20 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 21 | 22 | # Set the nonce only to specific directives 23 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 24 | 25 | # Report CSP violations to a specified URI 26 | # For further information see the following documentation: 27 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 28 | # Rails.application.config.content_security_policy_report_only = true 29 | -------------------------------------------------------------------------------- /app/models/concerns/counter/changable.rb: -------------------------------------------------------------------------------- 1 | module Counter::Changable 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | def has_changed? attribute, from: Counter::Any, to: Counter::Any 6 | from = Counter::Any.instance if from == Counter::Any 7 | to = Counter::Any.instance if to == Counter::Any 8 | 9 | return false unless previous_changes.key?(attribute) 10 | 11 | old_value, new_value = previous_changes[attribute] 12 | 13 | # Return true on Counter::any changes 14 | return true if from.instance_of?(Counter::Any) && to.instance_of?(Counter::Any) 15 | 16 | from_condition = case from 17 | when Counter::Any then true 18 | when Proc then from.call(old_value) 19 | else 20 | from == old_value 21 | end 22 | 23 | to_condition = case to 24 | when Counter::Any then true 25 | when Proc then to.call(new_value) 26 | else 27 | to == new_value 28 | end 29 | 30 | # # Return false if nothing changed 31 | # return false if old_value == new_value 32 | 33 | # # Check if the value change from 34 | # return new_value == to if from.instance_of?(Any) 35 | # # Check if the value change to 36 | # return old_value == from if to.instance_of?(Any) 37 | 38 | # Check if the value change from to 39 | from_condition && to_condition 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /app/models/concerns/counter/increment.rb: -------------------------------------------------------------------------------- 1 | module Counter::Increment 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | def increment! by: 1 6 | perform_update! by 7 | end 8 | 9 | def decrement! by: 1 10 | perform_update!(-by) 11 | end 12 | 13 | def perform_update! increment 14 | return if increment.zero? 15 | 16 | with_lock do 17 | update! value: value + increment 18 | end 19 | end 20 | 21 | def add_item item 22 | if definition.calculated_value? 23 | recalc! 24 | return 25 | end 26 | 27 | return unless increment?(item, :create) 28 | 29 | increment! by: increment_from_item(item) 30 | end 31 | 32 | def remove_item item 33 | if definition.calculated_value? 34 | recalc! 35 | return 36 | end 37 | 38 | return unless decrement?(item, :delete) 39 | 40 | decrement! by: increment_from_item(item) 41 | end 42 | 43 | def update_item item 44 | if definition.calculated_value? 45 | recalc! 46 | return 47 | end 48 | 49 | if increment?(item, :update) 50 | increment! by: increment_from_item(item) 51 | end 52 | 53 | if decrement?(item, :update) 54 | decrement! by: increment_from_item(item) 55 | end 56 | end 57 | 58 | # How much should we increment the counter 59 | def increment_from_item item 60 | 1 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /test/integration/conditional_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ConditionalTest < ActiveSupport::TestCase 4 | test "conditionally increments the counter" do 5 | u = User.create! 6 | Product.create! user: u, price: 100 7 | assert_equal 0, u.premium_products_counter.value 8 | product = Product.create! user: u, price: 1000 9 | assert_equal 1, u.premium_products_counter.value 10 | product.update! price: 1001 11 | assert_equal 1, u.premium_products_counter.value 12 | product.destroy 13 | assert_equal 0, u.premium_products_counter.value 14 | end 15 | 16 | test "conditionally decrements the counter when updating" do 17 | u = User.create! 18 | product = Product.create! user: u, price: 1000 19 | assert_equal 1, u.premium_products_counter.value 20 | product.update! price: 100 21 | assert_equal 0, u.premium_products_counter.value 22 | end 23 | 24 | test "conditionally decrements the counter when deleting" do 25 | u = User.create! 26 | product = Product.create! user: u, price: 1000 27 | assert_equal 1, u.premium_products_counter.value 28 | product.destroy 29 | assert_equal 0, u.premium_products_counter.value 30 | end 31 | 32 | test "calculated values never accept item" do 33 | user = User.create! 34 | product = Product.create!(user:, price: 1000) 35 | 36 | order = Order.create!(user:, product:, price: 1000) 37 | refute user.returned_order_counter.accept_item?(order, "x") 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /app/models/counter/value.rb: -------------------------------------------------------------------------------- 1 | # == Schema Information 2 | # 3 | # Table name: counter_values 4 | # 5 | # id :integer not null, primary key 6 | # type :string indexed 7 | # name :string indexed 8 | # value :integer default(0) 9 | # parent_type :string indexed => [parent_id] 10 | # parent_id :integer indexed => [parent_type] 11 | # created_at :datetime not null 12 | # updated_at :datetime not null 13 | # 14 | class Counter::Value < ApplicationRecord 15 | def self.table_name_prefix 16 | "counter_" 17 | end 18 | 19 | belongs_to :parent, polymorphic: true, optional: true 20 | 21 | validates_numericality_of :value 22 | 23 | def self.find_counter counter 24 | counter_name = if counter.is_a?(String) || counter.is_a?(Symbol) 25 | counter.to_s 26 | elsif counter.is_a?(Class) && counter.ancestors.include?(Counter::Definition) 27 | definition = counter.instance 28 | raise "Unable to find counter #{definition.name} via Counter::Value.find_counter. Use must use #{definition.model}#find_counter}" unless definition.global? 29 | 30 | counter.instance.record_name 31 | else 32 | counter.to_s 33 | end 34 | 35 | find_or_initialize_by name: counter_name 36 | end 37 | 38 | include Counter::Definable 39 | include Counter::Hooks 40 | include Counter::Increment 41 | include Counter::Reset 42 | include Counter::Recalculatable 43 | include Counter::Verifyable 44 | include Counter::Summable 45 | include Counter::Conditional 46 | include Counter::Calculated 47 | # include Counter::Hierarchical 48 | end 49 | -------------------------------------------------------------------------------- /test/integration/recalc_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class RecalcTest < ActiveSupport::TestCase 4 | test "an association counter can be recalculated" do 5 | u = User.create! 6 | u.products.create! price: 1000 7 | u.products.create! price: 10 8 | counter = u.premium_products_counter 9 | counter.update! value: 0 10 | counter.recalc! 11 | assert_equal 1, counter.reload.value 12 | end 13 | 14 | test "a manual counter can't be recalculated" do 15 | u = User.create! 16 | assert_raise Counter::Error do 17 | u.visits_counter.recalc! 18 | end 19 | end 20 | 21 | test "can recalculate a sum" do 22 | u = User.create! 23 | product = u.products.create! 24 | 3.times { u.orders.create! product: product, price: 10 } 25 | counter = product.order_revenue 26 | counter.reset! 27 | assert_equal 0, counter.value 28 | counter.recalc! 29 | assert_equal 30, counter.value 30 | end 31 | 32 | test "can recalculate a calculated counter" do 33 | u = User.create! 34 | u.visits_counter.increment! by: 100 35 | u.orders_counter.increment! by: 2 36 | u.conversion_rate.reset! 37 | u.conversion_rate.recalc! 38 | assert_equal 2, u.conversion_rate.value 39 | end 40 | 41 | test "calculated_values recalculate from their callable" do 42 | u = User.create!(grumpy: false) 43 | 44 | u.cigarette_counter.reset! 45 | assert_equal 0, u.cigarette_counter.value 46 | 47 | u.cigarette_counter.recalc! 48 | assert_equal 4, u.cigarette_counter.value.to_i 49 | 50 | u.grumpy! 51 | u.cigarette_counter.recalc! 52 | assert_equal 212, u.cigarette_counter.value.to_i 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/concerns/counter/sidekiq_reconciliation.rb: -------------------------------------------------------------------------------- 1 | module Counter::SidekiqReconciliation 2 | extend ActiveSupport::Concern 3 | 4 | ########################################################## Support for background reconciliation 5 | def add_item item 6 | record_counter_change 7 | enqueue_reconcilitation_job 8 | end 9 | 10 | def update_item item 11 | record_counter_change amount: 1 12 | enqueue_reconcilitation_job 13 | end 14 | 15 | def remove_item item 16 | record_counter_change amount: -1 17 | enqueue_reconcilitation_job 18 | end 19 | 20 | private 21 | 22 | def record_counter_change amount: 1 23 | Counter::Change.create! counter: self, increment: amount 24 | end 25 | 26 | # Enqueue a Sidekiq job 27 | def enqueue_reconcilitation_job 28 | Counter::ReconciliationJob.perform_now id 29 | end 30 | 31 | def filter_item item, on 32 | filtered_items = [] 33 | filters = @@count_filters[:create] || [] 34 | filters.all? do |filter| 35 | case filter.class 36 | when Symbol 37 | send filter, items 38 | when Proc 39 | instance_exec items, filter 40 | end 41 | end 42 | end 43 | 44 | def has_changed? attribute, from: Any.new, to: Any.new 45 | old_value, new_value = previous_changes[attribute] 46 | # Return true if the attribute changed at all 47 | return true if from.instance_of?(Any) && to.instance_of?(Any) 48 | 49 | return new_value == to if from.instance_of?(Any) 50 | return old_value == from if to.instance_of?(Any) 51 | 52 | old_value == from && new_value == to 53 | end 54 | 55 | class Any 56 | include Singleton 57 | def initialize 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/counter/integration/countable.rb: -------------------------------------------------------------------------------- 1 | module Counter::Countable 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | # Install the Rails callbacks if required 6 | after_create do 7 | each_counter_to_update do |counter| 8 | counter.add_item self 9 | end 10 | end 11 | 12 | after_update do 13 | each_counter_to_update do |counter| 14 | counter.update_item self 15 | end 16 | end 17 | 18 | after_destroy do 19 | each_counter_to_update do |counter| 20 | counter.remove_item self 21 | end 22 | end 23 | 24 | # Iterate over each counter that needs to be updated for this model 25 | # expects a block that takes a counter as an argument 26 | def each_counter_to_update 27 | # For each definition, find or create the counter on the parent 28 | self.class.counted_by.each do |counter_definition| 29 | next unless counter_definition.inverse_association 30 | 31 | parent_association = association(counter_definition.inverse_association) 32 | parent_association.load_target unless parent_association.loaded? 33 | parent_model = parent_association.target 34 | next unless parent_model 35 | 36 | if parent_model.class.reflect_on_association(:counters) && parent_model.class == counter_definition.model 37 | counter = parent_model.counters.find_or_create_counter!(counter_definition) 38 | yield counter if counter 39 | end 40 | end 41 | end 42 | end 43 | 44 | class_methods do 45 | def counted_by 46 | @counted_by 47 | end 48 | 49 | def add_counted_by config 50 | @counted_by ||= [] 51 | @counted_by << config 52 | end 53 | 54 | def inherited subclass 55 | super 56 | @counted_by.each { |c| subclass.add_counted_by c } 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /test/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /test/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /test/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /test/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/models/concerns/counter/verifyable.rb: -------------------------------------------------------------------------------- 1 | module Counter::Verifyable 2 | extend ActiveSupport::Concern 3 | 4 | def correct? 5 | # We can't verify these values 6 | return true if definition.global? 7 | 8 | old_value, new_value = verify 9 | old_value == new_value 10 | end 11 | 12 | def correct! 13 | # We can't verify these values 14 | return true if definition.global? 15 | 16 | old_value, new_value = verify 17 | 18 | requires_recalculation = old_value != new_value 19 | update! value: new_value if requires_recalculation 20 | 21 | !requires_recalculation 22 | end 23 | 24 | def verify 25 | if definition.calculated? 26 | [calculate, value] 27 | else 28 | [count_by_sql, value] 29 | end 30 | end 31 | 32 | class_methods do 33 | # on_error: raise, log, correct 34 | # Returns the number of incorrect counters 35 | def sample_and_verify scope: -> { all }, samples: 1000, verbose: true, on_error: :raise 36 | incorrect_counters = 0 37 | 38 | counters = Counter::Value.merge(scope) 39 | counter_range = counters.minimum(:id)..counters.maximum(:id) 40 | 41 | samples.times do 42 | random_id = rand(counter_range) 43 | counter = counters.where("id >= ?", random_id).limit(1).first 44 | next if counter.nil? 45 | 46 | if counter.definition.global? || counter.definition.calculated? 47 | puts "➡️ Skipping counter #{counter.name} (#{counter.id})" if verbose 48 | next 49 | end 50 | 51 | if counter.correct? 52 | puts "✅ Counter #{counter.id} is correct" if verbose 53 | else 54 | incorrect_counters += 1 55 | message = "❌ counter #{counter.name} (#{counter.id}) for #{counter.parent_type}##{counter.parent_id} has incorrect counter value. Expected #{counter.value} but got #{counter.count_by_sql}" 56 | 57 | case on_error 58 | when :raise then raise Counter::Error.new(message) 59 | when :log then Rails.logger.error message 60 | when :correct 61 | counter.correct! 62 | puts "🔧 Corrected counter #{counter.id}" if verbose 63 | end 64 | end 65 | sleep 0.1 66 | end 67 | incorrect_counters 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /test/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 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 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = false 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | 35 | # Store uploaded files on the local file system in a temporary directory. 36 | config.active_storage.service = :test 37 | 38 | config.action_mailer.perform_caching = false 39 | 40 | # Tell Action Mailer not to deliver emails to the real world. 41 | # The :test delivery method accumulates sent emails in the 42 | # ActionMailer::Base.deliveries array. 43 | config.action_mailer.delivery_method = :test 44 | 45 | # Print deprecation notices to the stderr. 46 | config.active_support.deprecation = :stderr 47 | 48 | # Raise exceptions for disallowed deprecations. 49 | config.active_support.disallowed_deprecation = :raise 50 | 51 | # Tell Active Support which deprecation messages to disallow. 52 | config.active_support.disallowed_deprecation_warnings = [] 53 | 54 | # Raises error for missing translations. 55 | # config.i18n.raise_on_missing_translations = true 56 | 57 | # Annotate rendered view with file names. 58 | # config.action_view.annotate_rendered_view_with_filenames = true 59 | end 60 | -------------------------------------------------------------------------------- /test/integration/increment_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CountersTest < ActiveSupport::TestCase 4 | test "increments the counter when an item is added" do 5 | u = User.create 6 | u.products.create! 7 | counter = u.counters.find_or_create_counter! ProductCounter 8 | assert_equal 1, counter.value 9 | end 10 | 11 | test "decrements the counter when an item is destroy" do 12 | u = User.create 13 | product = u.products.create! 14 | counter = u.counters.find_or_create_counter! ProductCounter 15 | assert_equal 1, counter.value 16 | product.destroy! 17 | assert_equal 0, counter.reload.value 18 | end 19 | 20 | test "decrements the counter when an newly-loaded item is destroy" do 21 | u = User.create 22 | product = u.products.create! 23 | # Reloading the product means the user association is no longer loaded 24 | product.reload 25 | product.destroy! 26 | assert_equal 0, u.products_counter.reload.value 27 | end 28 | 29 | test "does not change the counter when an item is updated" do 30 | u = User.create! 31 | product = u.products.create! 32 | counter = u.counters.find_counter ProductCounter 33 | assert_equal 1, counter.reload.value 34 | product.update! name: "new name" 35 | assert_equal 1, counter.reload.value 36 | end 37 | 38 | test "only try to increment counters for polymorphic associations with registered counters" do 39 | u = User.create! 40 | product = u.products.create! 41 | subscription = u.subscriptions.create! 42 | 43 | subscription_coupon = subscription.coupons.create!(amount: 1000) 44 | assert_equal false, subscription.respond_to?(:counters) 45 | 46 | counter = product.counters.find_counter(ProductDiscountsCounter) 47 | assert_equal nil, counter 48 | 49 | subscription_coupon.destroy! 50 | _product_coupon = product.coupons.create!(amount: 500) 51 | counter = product.counters.find_counter(ProductDiscountsCounter) 52 | assert_equal 500, counter.reload.value 53 | end 54 | 55 | test "calculated values do not incremement or decrement" do 56 | user = User.create! 57 | product = Product.create!(user:, price: 1000) 58 | 59 | Order.create!(user:, product:, price: 1000) 60 | assert_equal 500, user.returned_order_counter.value 61 | 62 | Order.create!(user:, product:, price: 1000) 63 | assert_equal 500, user.returned_order_counter.value 64 | 65 | user.orders.last.update!(price: 100000000) 66 | assert_equal 500, user.returned_order_counter.value 67 | 68 | Order.destroy_all 69 | assert_equal 500, user.returned_order_counter.value 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /test/integration/change_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class ChangesTest < ActiveSupport::TestCase 4 | test "no changes" do 5 | user = User.create! 6 | product = Product.create! user: user, price: 1000 7 | product = product.reload 8 | assert !product.has_changed?(:price) 9 | assert !product.has_changed?(:price, from: 1000) 10 | assert !product.has_changed?(:price, to: 1000) 11 | product.update! price: 1001 12 | assert !product.has_changed?(:price, to: ->(price) { price < 1000 }) 13 | assert !product.has_changed?(:price, from: ->(price) { price > 1000 }) 14 | end 15 | 16 | test "changed from ANY to ANY" do 17 | user = User.create! 18 | product = Product.create! user: user, price: 1000 19 | product.update! price: 2000 20 | assert product.has_changed?(:price) 21 | end 22 | 23 | test "changed from ANY implicit to value" do 24 | user = User.create! 25 | product = Product.create! user: user, price: 1000 26 | product.update! price: 2000 27 | assert product.has_changed?(:price, to: 2000) 28 | end 29 | 30 | test "changed from ANY to value" do 31 | user = User.create! 32 | product = Product.create! user: user, price: 1000 33 | product.update! price: 2000 34 | assert product.has_changed?(:price, from: Counter::Any, to: 2000) 35 | end 36 | 37 | test "changed from value to ANY" do 38 | user = User.create! 39 | product = Product.create! user: user, price: 1000 40 | product.update! price: 2000 41 | assert product.has_changed?(:price, from: 1000) 42 | end 43 | 44 | test "changed from block to ANY" do 45 | user = User.create! 46 | product = Product.create! user: user, price: 1000 47 | product.update! price: 2000 48 | assert product.has_changed?(:price, from: ->(p) { p < 2000 }) 49 | end 50 | 51 | test "changed from ANY to block" do 52 | user = User.create! 53 | product = Product.create! user: user, price: 1000 54 | product.update! price: 2000 55 | assert product.has_changed?(:price, to: ->(p) { p > 1000 }) 56 | end 57 | 58 | test "changed from block to block" do 59 | user = User.create! 60 | product = Product.create! user: user, price: 1000 61 | product.update! price: 2000 62 | assert product.has_changed?(:price, 63 | from: ->(p) { p < 2000 }, 64 | to: ->(p) { p > 1000 }) 65 | end 66 | 67 | test "unchanged from block to block" do 68 | user = User.create! 69 | product = Product.create! user: user, price: 2000 70 | product.update! price: 1000 71 | assert !product.has_changed?(:price, 72 | from: ->(p) { p < 2000 }, 73 | to: ->(p) { p > 1000 }) 74 | end 75 | 76 | test "unchanged from value to value" do 77 | user = User.create! 78 | product = Product.create! user: user, price: 1000 79 | product.update! price: 1000 80 | assert !product.has_changed?(:price, from: 1000) 81 | assert !product.has_changed?(:price, to: 1000) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /test/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | # Run rails dev:cache to toggle caching. 19 | if Rails.root.join("tmp", "caching-dev.txt").exist? 20 | config.action_controller.perform_caching = true 21 | config.action_controller.enable_fragment_cache_logging = 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 options). 34 | config.active_storage.service = :local 35 | 36 | # Don't care if the mailer can't send. 37 | config.action_mailer.raise_delivery_errors = false 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Print deprecation notices to the Rails logger. 42 | config.active_support.deprecation = :log 43 | 44 | # Raise exceptions for disallowed deprecations. 45 | config.active_support.disallowed_deprecation = :raise 46 | 47 | # Tell Active Support which deprecation messages to disallow. 48 | config.active_support.disallowed_deprecation_warnings = [] 49 | 50 | # Raise an error on page load if there are pending migrations. 51 | config.active_record.migration_error = :page_load 52 | 53 | # Highlight code that triggered database queries in logs. 54 | config.active_record.verbose_query_logs = true 55 | 56 | # Debug mode disables concatenation and preprocessing of assets. 57 | # This option may cause significant delays in view rendering with a large 58 | # number of complex assets. 59 | # config.assets.debug = true 60 | 61 | # Suppress logger output for asset requests. 62 | # config.assets.quiet = true 63 | 64 | # Raises error for missing translations. 65 | # config.i18n.raise_on_missing_translations = true 66 | 67 | # Annotate rendered view with file names. 68 | # config.action_view.annotate_rendered_view_with_filenames = true 69 | 70 | # Use an evented file watcher to asynchronously detect changes in source code, 71 | # routes, locales, etc. This feature depends on the listen gem. 72 | # config.file_watcher = ActiveSupport::EventedFileUpdateChecker 73 | 74 | # Uncomment if you wish to allow Action Cable access from any origin. 75 | # config.action_cable.disable_request_forgery_protection = true 76 | end 77 | -------------------------------------------------------------------------------- /test/dummy/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 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[8.0].define(version: 2025_08_28_185102) do 14 | create_table "counter_values", force: :cascade do |t| 15 | t.string "name" 16 | t.decimal "value", default: "0.0", null: false 17 | t.string "parent_type" 18 | t.integer "parent_id" 19 | t.datetime "created_at", null: false 20 | t.datetime "updated_at", null: false 21 | t.index ["name"], name: "index_counter_values_on_name" 22 | t.index ["parent_type", "parent_id", "name"], name: "unique_counter_values", unique: true 23 | t.index ["parent_type", "parent_id"], name: "index_counter_values_on_parent" 24 | end 25 | 26 | create_table "coupons", force: :cascade do |t| 27 | t.string "discountable_type", null: false 28 | t.integer "discountable_id", null: false 29 | t.integer "amount" 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.index ["discountable_type", "discountable_id"], name: "index_coupons_on_discountable" 33 | end 34 | 35 | create_table "orders", force: :cascade do |t| 36 | t.integer "user_id", null: false 37 | t.integer "product_id", null: false 38 | t.integer "price" 39 | t.datetime "created_at", null: false 40 | t.datetime "updated_at", null: false 41 | t.index ["product_id"], name: "index_orders_on_product_id" 42 | t.index ["user_id"], name: "index_orders_on_user_id" 43 | end 44 | 45 | create_table "products", force: :cascade do |t| 46 | t.integer "user_id", null: false 47 | t.string "name" 48 | t.integer "price" 49 | t.datetime "created_at", null: false 50 | t.datetime "updated_at", null: false 51 | t.index ["user_id"], name: "index_products_on_user_id" 52 | end 53 | 54 | create_table "subscriptions", force: :cascade do |t| 55 | t.integer "user_id", null: false 56 | t.string "name" 57 | t.integer "price" 58 | t.datetime "created_at", null: false 59 | t.datetime "updated_at", null: false 60 | t.index ["user_id"], name: "index_subscriptions_on_user_id" 61 | end 62 | 63 | create_table "users", force: :cascade do |t| 64 | t.datetime "created_at", null: false 65 | t.datetime "updated_at", null: false 66 | t.boolean "grumpy", default: false 67 | end 68 | 69 | add_foreign_key "orders", "products" 70 | add_foreign_key "orders", "users" 71 | add_foreign_key "products", "users" 72 | add_foreign_key "subscriptions", "users" 73 | end 74 | -------------------------------------------------------------------------------- /lib/counter/integration/counters.rb: -------------------------------------------------------------------------------- 1 | # This should be included in the model that has the counter 2 | # e.g. 3 | # class User < ApplicationModel 4 | # include Counter::Counters 5 | # has_many products 6 | # counter ProductCounter 7 | # end 8 | 9 | require "counter/definition" 10 | 11 | module Counter::Counters 12 | extend ActiveSupport::Concern 13 | 14 | included do 15 | has_many :counters, dependent: :destroy, class_name: "Counter::Value", as: :parent do 16 | # user.counters.find_counter ProductCounter 17 | def find_counter counter 18 | counter_name = if counter.is_a?(String) || counter.is_a?(Symbol) 19 | counter.to_s 20 | elsif counter.is_a?(Class) && counter.ancestors.include?(Counter::Definition) 21 | counter.instance.record_name 22 | else 23 | counter.to_s 24 | end 25 | 26 | find_by name: counter_name 27 | end 28 | 29 | # user.counters.find_counter ProductCounter 30 | def find_or_create_counter! counter 31 | counter_name = if counter.is_a?(String) || counter.is_a?(Symbol) 32 | counter.to_s 33 | elsif counter.is_a?(Counter::Definition) 34 | counter.record_name 35 | elsif counter.is_a?(Class) && counter.ancestors.include?(Counter::Definition) 36 | counter.instance.record_name 37 | else 38 | counter.to_s 39 | end 40 | 41 | Counter::Value.find_or_initialize_by(parent: proxy_association.owner, name: counter_name) 42 | end 43 | end 44 | 45 | # could even be a default scope?? 46 | scope :with_counters, -> { includes(:counters) } 47 | end 48 | 49 | class_methods do 50 | # counter ProductCounter 51 | # counter PremiumProductCounter, FreeProductCounter 52 | def counter *counter_definitions 53 | @counter_configs ||= [] 54 | 55 | counter_definitions = Array.wrap(counter_definitions) 56 | counter_definitions.each do |definition_class| 57 | definition = definition_class.instance 58 | definition.model = self 59 | 60 | counter_subquery = ->(counter_class) do 61 | record_name = counter_class.instance.record_name 62 | 63 | Counter::Value 64 | .select(:value) 65 | .where("parent_id = #{table_name}.id AND parent_type = '#{name}' AND name = '#{record_name}'") 66 | .limit(1) 67 | .to_sql 68 | end 69 | 70 | scope :with_counter_data_from, ->(*counter_classes) { 71 | subqueries = ["#{table_name}.*"] 72 | 73 | counter_classes.each do |counter_class| 74 | subquery = counter_subquery.call(counter_class) 75 | subqueries << Arel.sql("(#{subquery}) AS #{"#{counter_class.instance.name}_data"}") 76 | end 77 | 78 | select(subqueries) 79 | } 80 | 81 | # Expects a hash of counter classes and directions, like so: 82 | # order_by_counter ProductCounter => :desc, PremiumProductCounter => :asc 83 | scope :order_by_counter, ->(order_hash) { 84 | counter_classes = order_hash.keys.select { |counter_class| 85 | counter_class.is_a?(Class) && 86 | counter_class.ancestors.include?(Counter::Definition) 87 | } 88 | 89 | order_clauses = order_hash.map do |counter_class, direction| 90 | if counter_class.is_a?(String) || counter_class.is_a?(Symbol) 91 | "#{counter_class} #{direction.to_s.upcase}" 92 | elsif counter_class.ancestors.include?(Counter::Definition) 93 | "(#{counter_subquery.call(counter_class)}) #{direction.to_s.upcase}" 94 | end 95 | end 96 | 97 | with_counter_data_from(*counter_classes).order(Arel.sql(order_clauses.join(", "))) 98 | } 99 | 100 | scope :with_counters, -> { includes(:counters) } 101 | 102 | define_method definition.method_name do 103 | counters.find_or_create_counter!(definition) 104 | end 105 | 106 | @counter_configs << definition unless @counter_configs.include?(definition) 107 | 108 | association_name = definition.association_name 109 | if association_name.present? 110 | # Find the association on this model 111 | association_reflection = reflect_on_association(association_name) 112 | raise Counter::Error.new("#{association_name} does not exist #{self.name}") if association_reflection.nil? 113 | 114 | # Find the association classes 115 | association_class = association_reflection.class_name.constantize 116 | inverse_association = association_reflection.inverse_of 117 | raise Counter::Error.new("#{association_name} must have an inverse_of specified to be used in #{definition_class.name}") if inverse_association.nil? 118 | 119 | # Add the after_commit hook to the association's class 120 | association_class.include Counter::Countable 121 | 122 | # Update the definition with the association class and inverse association 123 | # gathered from the reflection 124 | definition.inverse_association = inverse_association.name 125 | definition.countable_model = association_class 126 | 127 | # Provide the Countable class with details about where it's counted 128 | association_class.add_counted_by definition 129 | end 130 | end 131 | end 132 | 133 | # Returns a list of Counter::Definitions 134 | def counter_configs 135 | @counter_configs || [] 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = 'http://assets.example.com' 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 38 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = 'wss://example.com/cable' 46 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [:request_id] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :resque 63 | # config.active_job.queue_name_prefix = "dummy_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Send deprecation notices to registered listeners. 76 | config.active_support.deprecation = :notify 77 | 78 | # Log disallowed deprecations. 79 | config.active_support.disallowed_deprecation = :log 80 | 81 | # Tell Active Support which deprecation messages to disallow. 82 | config.active_support.disallowed_deprecation_warnings = [] 83 | 84 | # Use default logging formatter so that PID and timestamp are not suppressed. 85 | config.log_formatter = ::Logger::Formatter.new 86 | 87 | # Use a different logger for distributed setups. 88 | # require "syslog/logger" 89 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 90 | 91 | if ENV["RAILS_LOG_TO_STDOUT"].present? 92 | logger = ActiveSupport::Logger.new(STDOUT) 93 | logger.formatter = config.log_formatter 94 | config.logger = ActiveSupport::TaggedLogging.new(logger) 95 | end 96 | 97 | # Do not dump schema after migrations. 98 | config.active_record.dump_schema_after_migration = false 99 | 100 | # Inserts middleware to perform automatic connection switching. 101 | # The `database_selector` hash is used to pass options to the DatabaseSelector 102 | # middleware. The `delay` is used to determine how long to wait after a write 103 | # to send a subsequent read to the primary. 104 | # 105 | # The `database_resolver` class is used by the middleware to determine which 106 | # database is appropriate to use based on the time delay. 107 | # 108 | # The `database_resolver_context` class is used by the middleware to set 109 | # timestamps for the last write to the primary. The resolver uses the context 110 | # class timestamps to determine how long to wait before reading from the 111 | # replica. 112 | # 113 | # By default Rails will store a last write timestamp in the session. The 114 | # DatabaseSelector middleware is designed as such you can define your own 115 | # strategy for connection switching and pass that into the middleware through 116 | # these configuration options. 117 | # config.active_record.database_selector = { delay: 2.seconds } 118 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 119 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 120 | end 121 | -------------------------------------------------------------------------------- /lib/counter/definition.rb: -------------------------------------------------------------------------------- 1 | # Example usage… 2 | # 3 | # class ProductCounter 4 | # include Counter::Definition 5 | # # This specifies the association we're counting 6 | # count :products 7 | # sum :price # optional 8 | # as "my_counter" 9 | # end 10 | class Counter::Definition 11 | include Singleton 12 | 13 | # Attributes set by Counters#counter integration: 14 | attr_accessor :association_name 15 | # Set the model we're attached to (set by Counters#counter) 16 | attr_accessor :model 17 | # Set the thing we're counting (set by Counters#counter) 18 | attr_accessor :countable_model 19 | # Set the inverse association (i.e., from the products to the user) 20 | attr_accessor :inverse_association 21 | # When using sum, set the column we're summing 22 | attr_accessor :column_to_count 23 | # Test if we should count items using conditions 24 | attr_writer :conditions 25 | attr_writer :conditional 26 | # Set the name of the counter (used as the method name) 27 | attr_accessor :method_name 28 | attr_accessor :name 29 | # An array of all global counters 30 | attr_writer :global_counters 31 | # An array of Proc to run when the counter changes 32 | attr_writer :counter_hooks 33 | # The counters this calculated counter depends on 34 | attr_writer :dependent_counters 35 | # The block to call to calculate the counter 36 | attr_accessor :calculated_from 37 | # The block used to manually set the value 38 | attr_accessor :calculated_value 39 | # The counter's record name 40 | attr_writer :record_name 41 | 42 | # Is this a counter which sums a column? 43 | def sum? 44 | column_to_count.present? 45 | end 46 | 47 | # Is this a global counter? i.e., not attached to a model 48 | def global? 49 | model.nil? 50 | end 51 | 52 | # Is this counter conditional? 53 | def conditional? 54 | @conditional 55 | end 56 | 57 | # Is this counter using a calculated value? 58 | def calculated_value? 59 | @calculated_value.present? 60 | end 61 | 62 | # Is this counter calculated from other counters? 63 | def calculated? 64 | !@calculated_from.nil? 65 | end 66 | 67 | # Is this a manual counter? 68 | # Manual counters are not automatically updated from an association 69 | # or calculated from other counters 70 | def manual? 71 | association_name.nil? && !calculated? 72 | end 73 | 74 | # for global counter instances to find their definition 75 | def self.find_definition name 76 | Counter::Definition.instance.global_counters.find { |c| c.name == name } 77 | end 78 | 79 | # Access the counter value for global counters 80 | def self.counter 81 | raise "Unable to find counter instances via #{name}#counter. Use must use #{instance.model}#find_counter or #{instance.model}##{instance.counter_name}" unless instance.global? 82 | 83 | Counter::Value.find_counter self 84 | end 85 | 86 | def self.record_name(value) 87 | instance.record_name = value.to_s 88 | end 89 | 90 | # What we record in Counter::Value#name 91 | def record_name 92 | return @record_name if @record_name.present? 93 | return name if global? 94 | return "#{model.name.underscore}-#{association_name}" if association_name.present? 95 | "#{model.name.underscore}-#{name}" 96 | end 97 | 98 | def conditions 99 | @conditions ||= {} 100 | @conditions 101 | end 102 | 103 | def global_counters 104 | @global_counters ||= [] 105 | @global_counters 106 | end 107 | 108 | def counter_hooks 109 | @counter_hooks ||= [] 110 | @counter_hooks 111 | end 112 | 113 | def dependent_counters 114 | @dependent_counters ||= [] 115 | @dependent_counters 116 | end 117 | 118 | # Set the association we're counting 119 | def self.count association_name, as: "#{association_name}_counter" 120 | instance.association_name = association_name 121 | instance.name = as.to_s 122 | # How the counter can be accessed e.g. counter.products_counter 123 | instance.method_name = as.to_s 124 | end 125 | 126 | def self.calculated_value(calculation, association: nil) 127 | instance.association_name = association 128 | instance.calculated_value = calculation 129 | set_default_name 130 | end 131 | 132 | def self.set_default_name 133 | instance.name ||= to_s.underscore 134 | instance.method_name ||= to_s.underscore 135 | end 136 | 137 | def self.global 138 | Counter::Definition.instance.global_counters << instance 139 | end 140 | 141 | def self.calculated_from *dependent_counters, &block 142 | instance.dependent_counters = dependent_counters 143 | instance.calculated_from = block 144 | 145 | dependent_counters.each do |dependent_counter| 146 | # Install after_change hooks on the dependent counters 147 | dependent_counter.after_change :update_calculated_counters 148 | dependent_counter.define_method :update_calculated_counters do |counter, _old_value, _new_value| 149 | # Fetch all the counters which depend on this one 150 | calculated_counters = counter.parent.class.counter_configs.select { |c| 151 | c.dependent_counters.include?(counter.definition.class) 152 | } 153 | 154 | calculated_counters = calculated_counters.map { |c| counter.parent.counters.find_or_create_counter!(c) } 155 | # calculate the new values 156 | calculated_counters.each(&:calculate!) 157 | end 158 | end 159 | end 160 | 161 | # Set the name of the counter 162 | def self.as name 163 | instance.name = name.to_s 164 | instance.method_name = name.to_s 165 | end 166 | 167 | # Get the name of the association we're counting 168 | def self.association_name 169 | instance.association_name 170 | end 171 | 172 | # Set the column we're summing. Leave blank to count the number of items 173 | def self.sum column_name 174 | instance.column_to_count = column_name 175 | end 176 | 177 | # Define a conditional filter 178 | def self.on action, &block 179 | instance.conditional = true 180 | 181 | conditions = Counter::Conditions.new 182 | conditions.instance_eval(&block) 183 | 184 | instance.conditions[action] ||= [] 185 | instance.conditions[action] << conditions 186 | end 187 | 188 | def self.after_change block 189 | instance.counter_hooks << block 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /test/integration/definition_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class DefinitionTest < ActiveSupport::TestCase 4 | test "configures the counters on the parent model" do 5 | definitions = User.counter_configs 6 | assert_equal 7, definitions.length 7 | definition = definitions.first 8 | assert_equal ProductCounter, definition.class 9 | assert_equal User, definition.model 10 | assert_equal :products, definition.association_name 11 | assert_equal :user, definition.inverse_association 12 | end 13 | 14 | test "configures the thing being counted" do 15 | definitions = Product.counted_by 16 | assert_equal 2, definitions.length 17 | definition = definitions.first 18 | assert_kind_of ProductCounter, definition 19 | assert_equal User, definition.model 20 | assert_equal :products, definition.association_name 21 | assert_equal :user, definition.inverse_association 22 | end 23 | 24 | test "find a counter by calling method name" do 25 | u = User.create! 26 | u.counters.create! name: "user-products" 27 | assert_equal Counter::Value, u.products_counter.class 28 | assert_kind_of ProductCounter, u.products_counter.definition 29 | end 30 | 31 | test "counter has a definition" do 32 | u = User.create! 33 | counter = u.counters.create! name: "user-products" 34 | assert_kind_of ProductCounter, counter.definition 35 | end 36 | 37 | test "finds a counter" do 38 | u = User.create! 39 | assert_nil u.counters.find_counter(ProductCounter) 40 | assert_nil u.counters.find_counter("user-products") 41 | counter = u.counters.create! name: "user-products" 42 | assert_equal counter, u.counters.find_counter("user-products") 43 | assert_equal counter, u.counters.find_counter(ProductCounter) 44 | end 45 | 46 | test "adds a method for the counter" do 47 | u = User.create! 48 | counter = u.premium_products_counter 49 | assert_equal 0, counter.value 50 | assert_equal Counter::Value, counter.class 51 | assert_equal "user-premium_products", counter.name 52 | assert_kind_of PremiumProductCounter, counter.definition 53 | end 54 | 55 | test "allows counters to configure the counter name" do 56 | u = User.create! 57 | product = Product.create! user: u 58 | assert_equal "order_revenue", product.order_revenue.definition.name 59 | end 60 | 61 | test "finds or creates a counter" do 62 | u = User.create! 63 | counter = u.counters.find_or_create_counter!(ProductCounter) 64 | assert_equal Counter::Value, counter.class 65 | assert_equal "user-products", counter.name 66 | assert counter.new_record? 67 | assert_equal u, counter.parent 68 | u.counters.find_counter(ProductCounter) 69 | assert 1, Counter::Value.count 70 | end 71 | 72 | test "loads all counters" do 73 | u = User.create 74 | u.counters.create! name: "user-products" 75 | assert User.with_counters.first.counters.loaded? 76 | end 77 | 78 | test "do not blow up if a counter hasn't been created" do 79 | u = User.create 80 | # No counter for products has been created but this should 81 | # still work and return a new instance 82 | assert u.products_counter.new_record? 83 | end 84 | 85 | test "counters can just be their own thing, not associated with an association" do 86 | u = User.create! 87 | visits_counter = u.visits_counter 88 | assert_kind_of Counter::Value, visits_counter 89 | visits_counter.increment! by: 10 90 | assert 10, visits_counter.value 91 | end 92 | 93 | test "define a global counter" do 94 | definition = GlobalOrderCounter.instance 95 | assert definition.global? 96 | assert_equal "total_orders", definition.name 97 | assert_kind_of Counter::Value, GlobalOrderCounter.counter 98 | GlobalOrderCounter.counter.increment! 99 | assert 1, GlobalOrderCounter.counter.value 100 | assert GlobalOrderCounter.instance, GlobalOrderCounter.counter.definition 101 | end 102 | 103 | test "sets the counter name" do 104 | assert_equal "visits_counter", VisitsCounter.instance.name 105 | end 106 | 107 | test "preloads the counters" do 108 | u = User.create! 109 | u.products.create! 110 | u.products.create! price: 1000 111 | 112 | assert User.with_counters.first.association(:counters).loaded? 113 | end 114 | 115 | test "loads the counter data" do 116 | u = User.create! 117 | 2.times { u.products.create! } 118 | u.products.create! price: 1000 119 | u = User.with_counter_data_from(ProductCounter, PremiumProductCounter).first 120 | 121 | assert_equal 3, u.products_counter_data 122 | assert_equal 1, u.premium_products_counter_data 123 | end 124 | 125 | test "orders the results by the counter data" do 126 | u1 = User.create! 127 | 2.times { u1.products.create! } 128 | u2 = User.create! 129 | 5.times { u2.products.create! } 130 | results = User.order_by_counter(ProductCounter => :desc) 131 | assert_equal [u2, u1], results 132 | end 133 | 134 | test "order is chainable" do 135 | u1 = User.create! 136 | 2.times { u1.products.create! } 137 | u2 = User.create! 138 | 5.times { u2.products.create! } 139 | results = User.order_by_counter(ProductCounter => :desc).where(id: u1.id).pluck :id 140 | assert_equal [u1.id], results 141 | results = User.where(id: u1.id).order_by_counter(ProductCounter => :desc) 142 | assert_equal [u1], results 143 | end 144 | 145 | test "orders the results with mixed counter data and attributes" do 146 | u1 = User.create! 147 | 2.times { u1.products.create! } 148 | u2 = User.create! 149 | 2.times { u2.products.create! } 150 | results = User.order_by_counter(ProductCounter => :desc, :id => :asc) 151 | assert_equal [u1, u2], results 152 | end 153 | 154 | test "manual counters aren't calculated" do 155 | u = User.create! 156 | # Calculated counter are not manual 157 | assert_equal false, u.conversion_rate.definition.manual? 158 | # Counters without associations are manual 159 | assert_equal true, u.visits_counter.definition.manual? 160 | # Global counters are manual 161 | assert_equal true, GlobalOrderCounter.counter.definition.manual? 162 | end 163 | 164 | test "subclasses should inherit counters from superclasses" do 165 | u = User.create! 166 | product = SpecialProduct.create! user: u, price: 10 167 | product.orders.create! price: 10, user: u 168 | assert_kind_of Counter::Value, product.order_revenue 169 | assert 10, product.order_revenue.value 170 | end 171 | 172 | test "calculated values can be defined" do 173 | assert_kind_of Proc, CigaretteCounter.instance.calculated_value 174 | end 175 | 176 | test "calculated values can define an association" do 177 | assert_kind_of Proc, ReturnedOrderCounter.instance.calculated_value 178 | assert_equal :orders, ReturnedOrderCounter.instance.association_name 179 | end 180 | 181 | test "calculated values set a default name" do 182 | assert_equal "cigarette_counter", CigaretteCounter.instance.name 183 | assert_equal "cigarette_counter", CigaretteCounter.instance.method_name 184 | end 185 | 186 | test "value record names can be defined" do 187 | assert_equal "users_returned_orders", ReturnedOrderCounter.instance.record_name 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | counterwise (0.1.6) 5 | rails (>= 8) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (8.0.2) 11 | actionpack (= 8.0.2) 12 | activesupport (= 8.0.2) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | zeitwerk (~> 2.6) 16 | actionmailbox (8.0.2) 17 | actionpack (= 8.0.2) 18 | activejob (= 8.0.2) 19 | activerecord (= 8.0.2) 20 | activestorage (= 8.0.2) 21 | activesupport (= 8.0.2) 22 | mail (>= 2.8.0) 23 | actionmailer (8.0.2) 24 | actionpack (= 8.0.2) 25 | actionview (= 8.0.2) 26 | activejob (= 8.0.2) 27 | activesupport (= 8.0.2) 28 | mail (>= 2.8.0) 29 | rails-dom-testing (~> 2.2) 30 | actionpack (8.0.2) 31 | actionview (= 8.0.2) 32 | activesupport (= 8.0.2) 33 | nokogiri (>= 1.8.5) 34 | rack (>= 2.2.4) 35 | rack-session (>= 1.0.1) 36 | rack-test (>= 0.6.3) 37 | rails-dom-testing (~> 2.2) 38 | rails-html-sanitizer (~> 1.6) 39 | useragent (~> 0.16) 40 | actiontext (8.0.2) 41 | actionpack (= 8.0.2) 42 | activerecord (= 8.0.2) 43 | activestorage (= 8.0.2) 44 | activesupport (= 8.0.2) 45 | globalid (>= 0.6.0) 46 | nokogiri (>= 1.8.5) 47 | actionview (8.0.2) 48 | activesupport (= 8.0.2) 49 | builder (~> 3.1) 50 | erubi (~> 1.11) 51 | rails-dom-testing (~> 2.2) 52 | rails-html-sanitizer (~> 1.6) 53 | activejob (8.0.2) 54 | activesupport (= 8.0.2) 55 | globalid (>= 0.3.6) 56 | activemodel (8.0.2) 57 | activesupport (= 8.0.2) 58 | activerecord (8.0.2) 59 | activemodel (= 8.0.2) 60 | activesupport (= 8.0.2) 61 | timeout (>= 0.4.0) 62 | activestorage (8.0.2) 63 | actionpack (= 8.0.2) 64 | activejob (= 8.0.2) 65 | activerecord (= 8.0.2) 66 | activesupport (= 8.0.2) 67 | marcel (~> 1.0) 68 | activesupport (8.0.2) 69 | base64 70 | benchmark (>= 0.3) 71 | bigdecimal 72 | concurrent-ruby (~> 1.0, >= 1.3.1) 73 | connection_pool (>= 2.2.5) 74 | drb 75 | i18n (>= 1.6, < 2) 76 | logger (>= 1.4.2) 77 | minitest (>= 5.1) 78 | securerandom (>= 0.3) 79 | tzinfo (~> 2.0, >= 2.0.5) 80 | uri (>= 0.13.1) 81 | annotate (2.6.5) 82 | activerecord (>= 2.3.0) 83 | rake (>= 0.8.7) 84 | ansi (1.5.0) 85 | ast (2.4.3) 86 | base64 (0.3.0) 87 | benchmark (0.4.1) 88 | bigdecimal (3.2.2) 89 | builder (3.3.0) 90 | concurrent-ruby (1.3.5) 91 | connection_pool (2.5.3) 92 | crass (1.0.6) 93 | date (3.4.1) 94 | debug (1.11.0) 95 | irb (~> 1.10) 96 | reline (>= 0.3.8) 97 | drb (2.2.3) 98 | erb (5.0.2) 99 | erubi (1.13.1) 100 | globalid (1.2.1) 101 | activesupport (>= 6.1) 102 | i18n (1.14.7) 103 | concurrent-ruby (~> 1.0) 104 | io-console (0.8.1) 105 | irb (1.15.2) 106 | pp (>= 0.6.0) 107 | rdoc (>= 4.0.0) 108 | reline (>= 0.4.2) 109 | json (2.13.0) 110 | language_server-protocol (3.17.0.5) 111 | lint_roller (1.1.0) 112 | logger (1.7.0) 113 | loofah (2.24.1) 114 | crass (~> 1.0.2) 115 | nokogiri (>= 1.12.0) 116 | mail (2.8.1) 117 | mini_mime (>= 0.1.1) 118 | net-imap 119 | net-pop 120 | net-smtp 121 | marcel (1.0.4) 122 | mini_mime (1.1.5) 123 | minitest (5.25.5) 124 | minitest-reporters (1.7.1) 125 | ansi 126 | builder 127 | minitest (>= 5.0) 128 | ruby-progressbar 129 | net-imap (0.5.9) 130 | date 131 | net-protocol 132 | net-pop (0.1.2) 133 | net-protocol 134 | net-protocol (0.2.2) 135 | timeout 136 | net-smtp (0.5.1) 137 | net-protocol 138 | nio4r (2.7.4) 139 | nokogiri (1.18.9-aarch64-linux-gnu) 140 | racc (~> 1.4) 141 | nokogiri (1.18.9-aarch64-linux-musl) 142 | racc (~> 1.4) 143 | nokogiri (1.18.9-arm-linux-gnu) 144 | racc (~> 1.4) 145 | nokogiri (1.18.9-arm-linux-musl) 146 | racc (~> 1.4) 147 | nokogiri (1.18.9-arm64-darwin) 148 | racc (~> 1.4) 149 | nokogiri (1.18.9-x86_64-darwin) 150 | racc (~> 1.4) 151 | nokogiri (1.18.9-x86_64-linux-gnu) 152 | racc (~> 1.4) 153 | nokogiri (1.18.9-x86_64-linux-musl) 154 | racc (~> 1.4) 155 | parallel (1.27.0) 156 | parser (3.3.8.0) 157 | ast (~> 2.4.1) 158 | racc 159 | pp (0.6.2) 160 | prettyprint 161 | prettyprint (0.2.0) 162 | prism (1.4.0) 163 | psych (5.2.6) 164 | date 165 | stringio 166 | racc (1.8.1) 167 | rack (3.1.16) 168 | rack-session (2.1.1) 169 | base64 (>= 0.1.0) 170 | rack (>= 3.0.0) 171 | rack-test (2.2.0) 172 | rack (>= 1.3) 173 | rackup (2.2.1) 174 | rack (>= 3) 175 | rails (8.0.2) 176 | actioncable (= 8.0.2) 177 | actionmailbox (= 8.0.2) 178 | actionmailer (= 8.0.2) 179 | actionpack (= 8.0.2) 180 | actiontext (= 8.0.2) 181 | actionview (= 8.0.2) 182 | activejob (= 8.0.2) 183 | activemodel (= 8.0.2) 184 | activerecord (= 8.0.2) 185 | activestorage (= 8.0.2) 186 | activesupport (= 8.0.2) 187 | bundler (>= 1.15.0) 188 | railties (= 8.0.2) 189 | rails-dom-testing (2.3.0) 190 | activesupport (>= 5.0.0) 191 | minitest 192 | nokogiri (>= 1.6) 193 | rails-html-sanitizer (1.6.2) 194 | loofah (~> 2.21) 195 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 196 | railties (8.0.2) 197 | actionpack (= 8.0.2) 198 | activesupport (= 8.0.2) 199 | irb (~> 1.13) 200 | rackup (>= 1.0.0) 201 | rake (>= 12.2) 202 | thor (~> 1.0, >= 1.2.2) 203 | zeitwerk (~> 2.6) 204 | rainbow (3.1.1) 205 | rake (13.3.0) 206 | rdoc (6.14.2) 207 | erb 208 | psych (>= 4.0.0) 209 | regexp_parser (2.10.0) 210 | reline (0.6.2) 211 | io-console (~> 0.5) 212 | rubocop (1.75.8) 213 | json (~> 2.3) 214 | language_server-protocol (~> 3.17.0.2) 215 | lint_roller (~> 1.1.0) 216 | parallel (~> 1.10) 217 | parser (>= 3.3.0.2) 218 | rainbow (>= 2.2.2, < 4.0) 219 | regexp_parser (>= 2.9.3, < 3.0) 220 | rubocop-ast (>= 1.44.0, < 2.0) 221 | ruby-progressbar (~> 1.7) 222 | unicode-display_width (>= 2.4.0, < 4.0) 223 | rubocop-ast (1.46.0) 224 | parser (>= 3.3.7.2) 225 | prism (~> 1.4) 226 | rubocop-performance (1.25.0) 227 | lint_roller (~> 1.1) 228 | rubocop (>= 1.75.0, < 2.0) 229 | rubocop-ast (>= 1.38.0, < 2.0) 230 | ruby-progressbar (1.13.0) 231 | securerandom (0.4.1) 232 | sqlite3 (2.7.3-aarch64-linux-gnu) 233 | sqlite3 (2.7.3-aarch64-linux-musl) 234 | sqlite3 (2.7.3-arm-linux-gnu) 235 | sqlite3 (2.7.3-arm-linux-musl) 236 | sqlite3 (2.7.3-arm64-darwin) 237 | sqlite3 (2.7.3-x86_64-darwin) 238 | sqlite3 (2.7.3-x86_64-linux-gnu) 239 | sqlite3 (2.7.3-x86_64-linux-musl) 240 | standard (1.50.0) 241 | language_server-protocol (~> 3.17.0.2) 242 | lint_roller (~> 1.0) 243 | rubocop (~> 1.75.5) 244 | standard-custom (~> 1.0.0) 245 | standard-performance (~> 1.8) 246 | standard-custom (1.0.2) 247 | lint_roller (~> 1.0) 248 | rubocop (~> 1.50) 249 | standard-performance (1.8.0) 250 | lint_roller (~> 1.1) 251 | rubocop-performance (~> 1.25.0) 252 | stringio (3.1.7) 253 | thor (1.4.0) 254 | timeout (0.4.3) 255 | tzinfo (2.0.6) 256 | concurrent-ruby (~> 1.0) 257 | unicode-display_width (3.1.4) 258 | unicode-emoji (~> 4.0, >= 4.0.4) 259 | unicode-emoji (4.0.4) 260 | uri (1.0.3) 261 | useragent (0.16.11) 262 | websocket-driver (0.8.0) 263 | base64 264 | websocket-extensions (>= 0.1.0) 265 | websocket-extensions (0.1.5) 266 | zeitwerk (2.7.3) 267 | 268 | PLATFORMS 269 | aarch64-linux-gnu 270 | aarch64-linux-musl 271 | arm-linux-gnu 272 | arm-linux-musl 273 | arm64-darwin 274 | x86_64-darwin 275 | x86_64-linux-gnu 276 | x86_64-linux-musl 277 | 278 | DEPENDENCIES 279 | annotate 280 | counterwise! 281 | debug 282 | minitest-reporters 283 | sqlite3 284 | standard 285 | 286 | BUNDLED WITH 287 | 2.7.1 288 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Counter 2 | 3 | [![Tests](https://github.com/podia/counter/actions/workflows/ruby.yml/badge.svg)](https://github.com/podia/counter/actions/workflows/ruby.yml) 4 | 5 | Counting and aggregation library for Rails. 6 | 7 | - [Counter](#counter) 8 | - [Usage](#usage) 9 | - [Installation](#installation) 10 | - [Main concepts](#main-concepts) 11 | - [Basic usage](#basic-usage) 12 | - [Define a counter](#define-a-counter) 13 | - [Access counter values](#access-counter-values) 14 | - [Recalculate a counter](#recalculate-a-counter) 15 | - [Reset a counter](#reset-a-counter) 16 | - [Verify a counter](#verify-a-counter) 17 | - [Advanced usage](#advanced-usage) 18 | - [Sort or filter parent models by a counter value](#sort-or-filter-parent-models-by-a-counter-value) 19 | - [Aggregate a value (e.g. sum of order revenue)](#aggregate-a-value-eg-sum-of-order-revenue) 20 | - [Hooks](#hooks) 21 | - [Manual counters](#manual-counters) 22 | - [Manually calculating a value](#manually-calculating-a-value) 23 | - [Calculating a value from other counters](#calculating-a-value-from-other-counters) 24 | - [Defining a conditional counter](#defining-a-conditional-counter) 25 | - [Testing](#testing) 26 | - [Using Rspec](#using-rspec) 27 | - [In production](#in-production) 28 | - [TODO](#todo) 29 | - [Contributing](#contributing) 30 | - [License](#license) 31 | 32 | By the time you need Rails counter_caches you probably have other needs too. You probably want to sum column values, have conditional counters, and you probably have enough throughput that updating a single column value will cause lock contention problems. 33 | 34 | Counter is different from other solutions like [Rails counter caches](https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html) and [counter_culture](https://github.com/magnusvk/counter_culture): 35 | 36 | - Counters are objects. This makes it possible for them to have an API that allows you to define them, reset, and recalculate them. The definition of a counter is seperate from the value 37 | - Counters are persisted as a ActiveRecord models (_not_ a column of an existing model) 38 | - Counters can also perform aggregation (e.g. sum of column values instead of counting rows) or be calculated from other counters 39 | - Avoids lock-contention found in other solutions. By storing the value in another object we reduce the contention on the main e.g. User instance. This is only a small improvement though. By using the background change event pattern, we can batch perform the updates reducing the number of processes requiring a lock. 40 | - Incrementing counters can be safely performed in a background job via a change event/deferred reconciliation pattern (coming in a future iteration) 41 | 42 | ## Usage 43 | 44 | You probably shouldn't use it right now unless you're the sort of person that checks if something is poisonous by licking it—or you're working at Podia where we are testing it in production. 45 | 46 | ## Installation 47 | 48 | Add this line to your application's Gemfile: 49 | 50 | ```ruby 51 | gem 'counterwise', require: 'counter' 52 | ``` 53 | 54 | And then execute: 55 | 56 | ```bash 57 | $ bundle 58 | ``` 59 | 60 | Install the model migrations: 61 | 62 | ```bash 63 | $ rails counter:install:migrations 64 | ``` 65 | 66 | ## Main concepts 67 | 68 | ![](docs/data_model.png) 69 | 70 | `Counter::Definition` defines what the counter is, what model it's connected to, what association it counts, how the count is performed etc. You create a subclass of `Counter::Definition` and call a few class methods to configure it. The definition is available through `counter.definition` for any counter value… 71 | 72 | `Counter::Value` is the value of a counter. So, for example, a User might have many Posts, so a User would have a `counters` association containing a `Counter::Value` for the number of posts. Counters can be accessed via their name `user.posts_counter` or via the `find_counter` method on the association, e.g. `user.counters.find_counter PostCounter` 73 | 74 | ## Basic usage 75 | 76 | ### Define a counter 77 | 78 | Counters are defined in a seperate class using a small DSL. 79 | 80 | Given a `Store` with many `Order`s, it would be defined as… 81 | 82 | ```ruby 83 | class OrderCounter < Counter::Definition 84 | count :orders 85 | end 86 | 87 | class Store < ApplicationRecord 88 | include Counter::Counters 89 | 90 | has_many :orders 91 | counter OrderCounter 92 | end 93 | ``` 94 | 95 | First we define the counter class itself using `count` to specify the association we're counting, then "attach" it to the parent Store model. 96 | 97 | By default, the counter will be available as `_counter`, e.g. `store.orders_counter`. To customise this, use the `as` method: 98 | 99 | ```ruby 100 | class OrderCounter < Counter::Definition 101 | include Counter::Counters 102 | count :orders 103 | as :total_orders 104 | end 105 | 106 | store.total_orders 107 | ``` 108 | 109 | The counter's value will be stored as a `Counter::Value` with the name prefixed by the model name. e.g. `store-total_orders` 110 | 111 | ### Access counter values 112 | 113 | Since counters are represented as objects, you need to call `value` on them to retrieve the count. 114 | 115 | ```ruby 116 | store.total_orders #=> Counter::Value 117 | store.total_orders.value #=> 200 118 | ``` 119 | 120 | ### Recalculate a counter 121 | 122 | Counters have a habit of drifting over time, particularly if ActiveRecords hooks aren't run (e.g. with a pure SQL data migration) so you need a method of re-counting the metric. Counters make this easy because they are objects in their own right. 123 | 124 | You could refresh a store's revenue stats with: 125 | 126 | ```ruby 127 | store.order_revenue.recalc! 128 | ``` 129 | 130 | this would use the definition of the counter, including any option to sum a column. In the case of conditional counters, they are expected to be attached to an association which matched the conditions so the recalculated count remains accurate. 131 | 132 | ### Reset a counter 133 | 134 | You can also reset a counter by calling `reset`. 135 | 136 | ```ruby 137 | store.order_revenue.reset 138 | ``` 139 | 140 | Since counters are ActiveRecord objects, you could also reset them using: 141 | 142 | ```ruby 143 | Counter::Value.update value: 0 144 | ``` 145 | 146 | ### Verify a counter 147 | 148 | You might like to check if a counter is correct 149 | 150 | ```ruby 151 | store.product_revenue.correct? #=> false 152 | ``` 153 | 154 | This will re-count / re-calculate the value and compare it to the current one. If you wish to also update the value when it's not correct, use `correct!`: 155 | 156 | ```ruby 157 | store.product_revenue #=>200 158 | store.product_revenue.reset! 159 | store.product_revenue #=>0 160 | store.product_revenue.correct? #=> false 161 | store.product_revenue.correct! #=> false 162 | store.product_revenue #=>200 163 | ``` 164 | 165 | ## Advanced usage 166 | 167 | ### Sort or filter parent models by a counter value 168 | 169 | Say a Customer has a `total revenue` counter, and you'd like to sort the list of customers with the highest spenders at the top. Since the counts aren't stored on the Customer model, you can't just call `Customer.order(total_orders: :desc)`. Instead, Counterwise provides a convenience method to pull the counter values into the resultset. 170 | 171 | ```ruby 172 | Customer.order_by_counter TotalRevenueCounter => :desc 173 | 174 | # You can sort by multiple counters or mix counters and model attributes 175 | Customer.order_by_counter TotalRevenueCounter => :desc, name: :asc 176 | ``` 177 | 178 | Under the hood, `order_by_counter` will uses `with_counter_data_from` to pull the counter values into the resultset. This is useful if you want to use the counter values in a `where` clause or `select` statement. 179 | 180 | ```ruby 181 | Customer.with_counter_data_from(TotalRevenueCounter).where("total_revenue_data > 1000") 182 | ``` 183 | 184 | These methods pull in the counter data itself but don't include the counter instances themselves. To do this, call 185 | 186 | ```ruby 187 | customers = Customer.with_counters TotalRevenueCounter 188 | # Since the counters are now preloaded, this avoids an N+1 query 189 | customers.each &:total_revenue 190 | ``` 191 | 192 | ### Aggregate a value (e.g. sum of order revenue) 193 | 194 | Sometimes you don'y want to count the number of orders but instead sum the value of those orders.. 195 | 196 | Given an ActiveRecord model `Order`, we can count a storefront's revenue like so 197 | 198 | ```ruby 199 | class Store < ApplicationRecord 200 | include Counter::Counters 201 | 202 | counter OrderRevenue 203 | end 204 | ``` 205 | 206 | Define the counter like so 207 | 208 | ```ruby 209 | class OrderRevenue < Counter::Definition 210 | count :orders 211 | sum :total_price 212 | end 213 | ``` 214 | 215 | and access it like 216 | 217 | ```ruby 218 | store.orders.create total_price: 100 219 | store.orders.create total_price: 100 220 | store.order_revenue.value #=> 200 221 | ``` 222 | 223 | ### Hooks 224 | 225 | You can add an `after_change` hook to your counter definition to perform some action when the counter is updated. For example, you might want to send a notification when a counter reaches a certain value. 226 | 227 | ```ruby 228 | class OrderRevenueCounter < Counter::Definition 229 | count :orders, as: :order_revenue 230 | sum :price 231 | 232 | after_change :send_congratulations_email 233 | 234 | # Only send an email when they cross $1000 235 | def send_congratulations_email counter, old_value, new_value 236 | return unless old_value < 1000 && new_value >= 1000 237 | send_email "Congratulations! You've made #{to} dollars!" 238 | end 239 | end 240 | ``` 241 | 242 | ### Manual counters 243 | 244 | Most counters are associated with a model instance and association—these counters are automatically incremented when the associated collection changes but sometimes you just need a manual counter that you can increment. 245 | 246 | Manual counters just need a name 247 | 248 | ```ruby 249 | class TotalOrderCounter < Counter::Definition 250 | as "total_orders" 251 | end 252 | 253 | TotalOrderCounter.counter.value #=> 5 254 | TotalOrderCounter.counter.increment! #=> 6 255 | ``` 256 | 257 | ### Manually calculating a value 258 | 259 | There are edge cases where a counter can't be calculated by associations or other counters. You can tell the counter its value manually, in these cases, by using `calculated_value`. Calculated value takes a lambda and an association. The lambda is called each time an associated record is created, updated, or destroyed. 260 | 261 | ```ruby 262 | class RiskyOrdersCounter < Counter::Definition 263 | calculated_value ->(customer) { customer.orders.risky.count }, association: :orders 264 | end 265 | 266 | class Customer 267 | include Counters::Counter 268 | counter RiskyOrdersCounter 269 | end 270 | 271 | class Order 272 | include Counter::Changable 273 | end 274 | ``` 275 | 276 | You may want to change the auto-generated name of the counter value. In this case, you can provide that with `record_name`. 277 | 278 | ```ruby 279 | class RiskyOrdersCounter < Counter::Definition 280 | calculated_value ->(customer) { customer.orders.risky.count }, association: :orders 281 | record_name :risky_customer_orders 282 | end 283 | ``` 284 | 285 | ### Calculating a value from other counters 286 | 287 | You may also need have a common need to calculate a value from other counters. For example, given counters for the number of purchases and the number of visits, you might want to calculate the conversion rate. You can do this with a `calculate_from` block. 288 | 289 | ```ruby 290 | class ConversionRateCounter < Counter::Definition 291 | count nil, as: "conversion_rate" 292 | 293 | calculated_from VisitsCounter, OrdersCounter do |visits, orders| 294 | (orders.value.to_f / visits.value) * 100 295 | end 296 | end 297 | ``` 298 | 299 | This recalculates the conversion rate each time the visits or order counters are updated. If either dependant counter is not present, the calculation will not be run (i.e., visits and order will never be nil). 300 | 301 | ### Defining a conditional counter 302 | 303 | Conditional counters allow you to count a subset of an association, like just the premium product with a price >= 1000. 304 | 305 | ```ruby 306 | class Product < ApplicationRecord 307 | include Counter::Counters 308 | include Counter::Changable 309 | 310 | belongs_to :user 311 | 312 | scope :premium, -> { where("price >= 1000") } 313 | 314 | def premium? 315 | price >= 1000 316 | end 317 | end 318 | ``` 319 | 320 | Conditional counters are more complex to define since we also need to specify when the counter should be incremented or decremented, for each create/delete/update. 321 | 322 | ```ruby 323 | class PremiumProductCounter < Counter::Definition 324 | # Define the association we're counting 325 | count :premium_products 326 | 327 | on :create do 328 | increment_if ->(product) { product.premium? } 329 | end 330 | 331 | on :delete do 332 | decrement_if ->(product) { product.premium? } 333 | end 334 | 335 | on :update do 336 | increment_if ->(product) { 337 | product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 } 338 | } 339 | 340 | decrement_if ->(product) { 341 | product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 } 342 | } 343 | end 344 | end 345 | ``` 346 | 347 | There is a lot going on here! 348 | 349 | First, we define the counter on a scoped association. This ensures that when we call `counter.recalc()` we will count using the association's SQL to get the correct results. 350 | 351 | We also define several conditions that operate on the instance level, i.e. when we create/update/delete an instance. On `create` and `delete` we define a block to determine if the counter should be updated. In this case, we only increment the counter when a premium product is created, and only decrement it when a premium product is deleted. 352 | 353 | `update` is more complex because there are two scenarios: either a product has been updated to make it premium or downgrade from premium to some other state. On update, we increment the counter if the price has gone above 1000; and decrement is the price has now gone below 1000. 354 | 355 | We use the `has_changed?` helper to query the ActiveRecord `previous_changes` hash and check what has changed. You can specify either Procs or values for `from`/`to`. If you only specify a `from` value, `to` will default to "any value" (Counter::Any.instance) 356 | 357 | Conditional counters work best with a single attribute. If the counter is conditional on e.g. confirmed and subscribed, the update tracking logic becomes very complex especially if the values are both updated at the same time. The solution to this is hopefully Rails generated columns in 7.1 so you can store a "subscribed_and_confirmed" column and check the value of that instead. Rails dirty tracking will need to work with generated columns though; see [this PR](https://github.com/rails/rails/pull/48628). 358 | 359 | ## Testing 360 | 361 | ### Using Rspec 362 | 363 | If you use RSpec, you can include `Counter::RSpecMatchers` on your helpers and test your counter definitions. 364 | 365 | ```ruby 366 | require "counter/rspec/matchers" 367 | 368 | RSpec.configure do |config| 369 | config.include Counter::RSpecMatchers, type: :counter 370 | end 371 | ``` 372 | 373 | Now you can test your counter definitions like so: 374 | 375 | ```ruby 376 | require "rails_helper" 377 | 378 | RSpec.describe PremiumProductCounter, type: :counter do 379 | let(:store) { create(:store) } 380 | 381 | describe "on :create" do 382 | context "when the product is premium" do 383 | it "increments the counter" do 384 | expect { create(:product, :premium, store: store) }.to increment_counter_for(described_class, store) 385 | end 386 | end 387 | 388 | context "when the product is not premium" do 389 | it "doesn't increment the counter" do 390 | expect { create(:product, store: store) }.not_to increment_counter_for(described_class, store) 391 | end 392 | end 393 | end 394 | 395 | describe "on :delete" do 396 | context "when the product is premium" do 397 | it "decrements the counter" do 398 | expect { create(:product, :premium, store: store) }.to decrement_counter_for(described_class, store) 399 | end 400 | end 401 | 402 | context "when the product is not premium" do 403 | it "doesn't decrement the counter" do 404 | expect { create(:product, store: store) }.not_to decrement_counter_for(described_class, store) 405 | end 406 | end 407 | end 408 | end 409 | ``` 410 | 411 | ### In production 412 | 413 | > test in prod or live a lie — Charity Majors 414 | 415 | It's very useful to verify the accuracy of the counters in production, especially if you are concerned about conditional counters etc causing counter drift over time. 416 | 417 | A simple approach would be: 418 | 419 | ```ruby 420 | Counter::Value.all.each &:correct! 421 | ``` 422 | 423 | If you have a large number of counters though it's best to take a sampling approach to randomly select a counter and verify that the value is correct 424 | 425 | ```ruby 426 | Counter::Value.sample_and_verify samples: 1000, verbose: true, on_error: :correct 427 | ``` 428 | 429 | Options: 430 | 431 | - scope — allows you to scope the counters to a particular model or set of models, e.g. `scope: -> { where("name LIKE 'store-%'") }`. By default, all counters are sampled 432 | - samples — the number of counters to sample. Default: 1000 433 | - verbose — print out the counter details and whether it was correct. Default: true 434 | - on_error — what to do when a counter is incorrect. `:correct` will correct the counter, `:raise` will raise an error, `:log` will log the error to Rails.logger. Default: :raise 435 | 436 | --- 437 | 438 | ## Release Instructions 439 | 440 | To release a new version of the counterwise gem: 441 | 442 | 1. **Merge changes**: Ensure all changes are merged into the main branch via pull requests. 443 | 444 | 2. **Update version**: Bump the version number in `lib/counter/version.rb`: 445 | 446 | ```ruby 447 | module Counter 448 | VERSION = "x.y.z" # Update to new version 449 | end 450 | ``` 451 | 452 | 3. **Commit and push version bump**: 453 | 454 | ```bash 455 | git add lib/counter/version.rb 456 | git commit -m "Bump version to x.y.z" 457 | git push origin main 458 | ``` 459 | 460 | 4. **Build and release**: 461 | ```bash 462 | gem build counter.gemspec 463 | gem push counterwise-x.y.z.gem 464 | ``` 465 | 466 | The gem will be available on [RubyGems.org](https://rubygems.org/gems/counterwise) within a few minutes. 467 | 468 | --- 469 | 470 | ## TODO 471 | 472 | See the asociated project in Github but roughly I'm thinking: 473 | 474 | - Implement the background job pattern for incrementing counters 475 | - Hierarchical counters. For example, a Site sends many Newsletters and each Newsletter results in many EmailMessages. Each EmailMessage can be marked as spam. How do you create counters for how many spam emails were sent at the Newsletter level and the Site level? 476 | - Time-based counters for analytics. Instead of a User having one OrderRevenue counter, they would have an OrderRevenue counter for each day. These counters would then be used to produce a chart of their product revenue over the month. Not sure if these are just special counters or something else entirely? Do they use the same ActiveRecord model? 477 | - In a similar vein of supporting different value types, can we support HLL values? Instead of increment an integer we add the items hash to a HyperLogLog so we can count unique items. An example would be counting site visits in a time-based daily counter, then combine the daily counts and still obtain an estimated number of monthly _unique_ visits. Again, not sure if this is the same ActiveRecord model or something different. 478 | - Actually start running this in production for basic use cases 479 | 480 | ## Contributing 481 | 482 | Bug reports and pull requests are welcome, especially around naming, internal APIs, bug fixes, and additional features. Please open an issue first if you're thinking of adding a new feature so we can discuss it. 483 | 484 | I'm unlikely to entertain suport for older Ruby or Rails versions, or databases other than Postgres. 485 | 486 | ## License 487 | 488 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 489 | --------------------------------------------------------------------------------