├── 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 | [](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 | 
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 |
--------------------------------------------------------------------------------