├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .vscode └── settings.json ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── app ├── assets │ ├── config │ │ └── counter_manifest.js │ ├── images │ │ └── counter │ │ │ └── .keep │ └── stylesheets │ │ └── counter │ │ └── .keep ├── controllers │ ├── .keep │ └── counters_controller.rb ├── helpers │ └── .keep ├── jobs │ ├── .keep │ └── counter │ │ └── reconciliation_job.rb ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ └── counter │ │ │ ├── Xhierarchical.rb │ │ │ ├── calculated.rb │ │ │ ├── changable.rb │ │ │ ├── conditional.rb │ │ │ ├── definable.rb │ │ │ ├── hooks.rb │ │ │ ├── increment.rb │ │ │ ├── recalculatable.rb │ │ │ ├── reset.rb │ │ │ ├── sidekiq_reconciliation.rb │ │ │ ├── summable.rb │ │ │ └── verifyable.rb │ └── counter │ │ └── value.rb └── views │ └── .keep ├── bin ├── rails └── test ├── config └── routes.rb ├── counter.gemspec ├── db └── migrate │ ├── 20210705154113_create_counter_values.rb │ └── 20210731224504_add_unique_index_to_counter_values.rb ├── docs └── data_model.png ├── lib ├── counter.rb ├── counter │ ├── any.rb │ ├── conditions.rb │ ├── definition.rb │ ├── engine.rb │ ├── error.rb │ ├── integration │ │ ├── countable.rb │ │ └── counters.rb │ ├── railtie.rb │ ├── rspec │ │ └── matchers.rb │ └── version.rb └── tasks │ └── counter_tasks.rake └── test ├── controllers └── counters_controller_test.rb ├── counter_test.rb ├── dummy ├── Rakefile ├── app │ ├── assets │ │ ├── config │ │ │ └── manifest.js │ │ ├── images │ │ │ └── .keep │ │ └── stylesheets │ │ │ └── application.css │ ├── channels │ │ └── application_cable │ │ │ ├── channel.rb │ │ │ └── connection.rb │ ├── controllers │ │ ├── application_controller.rb │ │ └── concerns │ │ │ └── .keep │ ├── helpers │ │ └── application_helper.rb │ ├── javascript │ │ └── packs │ │ │ └── application.js │ ├── jobs │ │ └── application_job.rb │ ├── mailers │ │ └── application_mailer.rb │ ├── models │ │ ├── application_record.rb │ │ ├── concerns │ │ │ └── .keep │ │ ├── conversion_rate_counter.rb │ │ ├── global_order_counter.rb │ │ ├── order.rb │ │ ├── order_revenue_counter.rb │ │ ├── orders_counter.rb │ │ ├── premium_product_counter.rb │ │ ├── product.rb │ │ ├── product_counter.rb │ │ ├── special_product.rb │ │ ├── user.rb │ │ └── visits_counter.rb │ └── views │ │ └── layouts │ │ ├── application.html.erb │ │ ├── mailer.html.erb │ │ └── mailer.text.erb ├── bin │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── cable.yml │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── assets.rb │ │ ├── backtrace_silencers.rb │ │ ├── content_security_policy.rb │ │ ├── cookies_serializer.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── permissions_policy.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── storage.yml ├── db │ ├── migrate │ │ ├── 20210729221240_create_users.rb │ │ ├── 20210729221340_create_products.rb │ │ ├── 20210729221419_create_orders.rb │ │ ├── 20230710225535_create_counter_values.counter.rb │ │ └── 20230710225537_add_unique_index_to_counter_values.counter.rb │ └── schema.rb ├── lib │ └── assets │ │ └── .keep ├── log │ └── .keep └── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ ├── apple-touch-icon-precomposed.png │ ├── apple-touch-icon.png │ └── favicon.ico ├── integration ├── calculated_test.rb ├── change_test.rb ├── conditional_test.rb ├── counters_test.rb ├── definition_test.rb ├── hooks_test.rb ├── increment_test.rb ├── recalc_test.rb ├── reset_test.rb ├── sum_test.rb └── verify_test.rb └── test_helper.rb /.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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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.0'] 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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | counterwise (0.1.5) 5 | rails (>= 7) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | actioncable (7.0.6) 11 | actionpack (= 7.0.6) 12 | activesupport (= 7.0.6) 13 | nio4r (~> 2.0) 14 | websocket-driver (>= 0.6.1) 15 | actionmailbox (7.0.6) 16 | actionpack (= 7.0.6) 17 | activejob (= 7.0.6) 18 | activerecord (= 7.0.6) 19 | activestorage (= 7.0.6) 20 | activesupport (= 7.0.6) 21 | mail (>= 2.7.1) 22 | net-imap 23 | net-pop 24 | net-smtp 25 | actionmailer (7.0.6) 26 | actionpack (= 7.0.6) 27 | actionview (= 7.0.6) 28 | activejob (= 7.0.6) 29 | activesupport (= 7.0.6) 30 | mail (~> 2.5, >= 2.5.4) 31 | net-imap 32 | net-pop 33 | net-smtp 34 | rails-dom-testing (~> 2.0) 35 | actionpack (7.0.6) 36 | actionview (= 7.0.6) 37 | activesupport (= 7.0.6) 38 | rack (~> 2.0, >= 2.2.4) 39 | rack-test (>= 0.6.3) 40 | rails-dom-testing (~> 2.0) 41 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 42 | actiontext (7.0.6) 43 | actionpack (= 7.0.6) 44 | activerecord (= 7.0.6) 45 | activestorage (= 7.0.6) 46 | activesupport (= 7.0.6) 47 | globalid (>= 0.6.0) 48 | nokogiri (>= 1.8.5) 49 | actionview (7.0.6) 50 | activesupport (= 7.0.6) 51 | builder (~> 3.1) 52 | erubi (~> 1.4) 53 | rails-dom-testing (~> 2.0) 54 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 55 | activejob (7.0.6) 56 | activesupport (= 7.0.6) 57 | globalid (>= 0.3.6) 58 | activemodel (7.0.6) 59 | activesupport (= 7.0.6) 60 | activerecord (7.0.6) 61 | activemodel (= 7.0.6) 62 | activesupport (= 7.0.6) 63 | activestorage (7.0.6) 64 | actionpack (= 7.0.6) 65 | activejob (= 7.0.6) 66 | activerecord (= 7.0.6) 67 | activesupport (= 7.0.6) 68 | marcel (~> 1.0) 69 | mini_mime (>= 1.1.0) 70 | activesupport (7.0.6) 71 | concurrent-ruby (~> 1.0, >= 1.0.2) 72 | i18n (>= 1.6, < 2) 73 | minitest (>= 5.1) 74 | tzinfo (~> 2.0) 75 | annotate (3.2.0) 76 | activerecord (>= 3.2, < 8.0) 77 | rake (>= 10.4, < 14.0) 78 | ansi (1.5.0) 79 | ast (2.4.2) 80 | builder (3.2.4) 81 | concurrent-ruby (1.2.2) 82 | crass (1.0.6) 83 | date (3.3.4) 84 | debug (1.8.0) 85 | irb (>= 1.5.0) 86 | reline (>= 0.3.1) 87 | erubi (1.12.0) 88 | globalid (1.2.1) 89 | activesupport (>= 6.1) 90 | i18n (1.14.1) 91 | concurrent-ruby (~> 1.0) 92 | io-console (0.6.0) 93 | irb (1.7.1) 94 | reline (>= 0.3.0) 95 | json (2.6.3) 96 | language_server-protocol (3.17.0.3) 97 | lint_roller (1.0.0) 98 | loofah (2.22.0) 99 | crass (~> 1.0.2) 100 | nokogiri (>= 1.12.0) 101 | mail (2.8.1) 102 | mini_mime (>= 0.1.1) 103 | net-imap 104 | net-pop 105 | net-smtp 106 | marcel (1.0.4) 107 | method_source (1.0.0) 108 | mini_mime (1.1.5) 109 | mini_portile2 (2.8.2) 110 | minitest (5.20.0) 111 | minitest-reporters (1.6.1) 112 | ansi 113 | builder 114 | minitest (>= 5.0) 115 | ruby-progressbar 116 | net-imap (0.4.10) 117 | date 118 | net-protocol 119 | net-pop (0.1.2) 120 | net-protocol 121 | net-protocol (0.2.2) 122 | timeout 123 | net-smtp (0.4.0.1) 124 | net-protocol 125 | nio4r (2.7.0) 126 | nokogiri (1.16.3-arm64-darwin) 127 | racc (~> 1.4) 128 | parallel (1.23.0) 129 | parser (3.2.2.3) 130 | ast (~> 2.4.1) 131 | racc 132 | racc (1.7.1) 133 | rack (2.2.9) 134 | rack-test (2.1.0) 135 | rack (>= 1.3) 136 | rails (7.0.6) 137 | actioncable (= 7.0.6) 138 | actionmailbox (= 7.0.6) 139 | actionmailer (= 7.0.6) 140 | actionpack (= 7.0.6) 141 | actiontext (= 7.0.6) 142 | actionview (= 7.0.6) 143 | activejob (= 7.0.6) 144 | activemodel (= 7.0.6) 145 | activerecord (= 7.0.6) 146 | activestorage (= 7.0.6) 147 | activesupport (= 7.0.6) 148 | bundler (>= 1.15.0) 149 | railties (= 7.0.6) 150 | rails-dom-testing (2.2.0) 151 | activesupport (>= 5.0.0) 152 | minitest 153 | nokogiri (>= 1.6) 154 | rails-html-sanitizer (1.6.0) 155 | loofah (~> 2.21) 156 | nokogiri (~> 1.14) 157 | railties (7.0.6) 158 | actionpack (= 7.0.6) 159 | activesupport (= 7.0.6) 160 | method_source 161 | rake (>= 12.2) 162 | thor (~> 1.0) 163 | zeitwerk (~> 2.5) 164 | rainbow (3.1.1) 165 | rake (13.0.6) 166 | regexp_parser (2.8.1) 167 | reline (0.3.6) 168 | io-console (~> 0.5) 169 | rexml (3.2.5) 170 | rubocop (1.52.1) 171 | json (~> 2.3) 172 | parallel (~> 1.10) 173 | parser (>= 3.2.2.3) 174 | rainbow (>= 2.2.2, < 4.0) 175 | regexp_parser (>= 1.8, < 3.0) 176 | rexml (>= 3.2.5, < 4.0) 177 | rubocop-ast (>= 1.28.0, < 2.0) 178 | ruby-progressbar (~> 1.7) 179 | unicode-display_width (>= 2.4.0, < 3.0) 180 | rubocop-ast (1.29.0) 181 | parser (>= 3.2.1.0) 182 | rubocop-performance (1.18.0) 183 | rubocop (>= 1.7.0, < 2.0) 184 | rubocop-ast (>= 0.4.0) 185 | ruby-progressbar (1.13.0) 186 | sqlite3 (1.6.3) 187 | mini_portile2 (~> 2.8.0) 188 | standard (1.29.0) 189 | language_server-protocol (~> 3.17.0.2) 190 | lint_roller (~> 1.0) 191 | rubocop (~> 1.52.0) 192 | standard-custom (~> 1.0.0) 193 | standard-performance (~> 1.1.0) 194 | standard-custom (1.0.1) 195 | lint_roller (~> 1.0) 196 | standard-performance (1.1.0) 197 | lint_roller (~> 1.0) 198 | rubocop-performance (~> 1.18.0) 199 | thor (1.3.1) 200 | timeout (0.4.1) 201 | tzinfo (2.0.6) 202 | concurrent-ruby (~> 1.0) 203 | unicode-display_width (2.4.2) 204 | websocket-driver (0.7.6) 205 | websocket-extensions (>= 0.1.0) 206 | websocket-extensions (0.1.5) 207 | zeitwerk (2.6.13) 208 | 209 | PLATFORMS 210 | ruby 211 | 212 | DEPENDENCIES 213 | annotate 214 | counterwise! 215 | debug 216 | minitest-reporters 217 | sqlite3 218 | standard 219 | 220 | BUNDLED WITH 221 | 2.1.4 222 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Counter 2 | 3 | [![Tests](https://github.com/podia/counter/actions/workflows/ruby.yml/badge.svg)](https://github.com/podia/counter/actions/workflows/ruby.yml) 4 | 5 | Counting and aggregation library for Rails. 6 | 7 | - [Counter](#counter) 8 | - [Usage](#usage) 9 | - [Installation](#installation) 10 | - [Main concepts](#main-concepts) 11 | - [Basic usage](#basic-usage) 12 | - [Define a counter](#define-a-counter) 13 | - [Access counter values](#access-counter-values) 14 | - [Recalculate a counter](#recalculate-a-counter) 15 | - [Reset a counter](#reset-a-counter) 16 | - [Verify a counter](#verify-a-counter) 17 | - [Advanced usage](#advanced-usage) 18 | - [Sort or filter parent models by a counter value](#sort-or-filter-parent-models-by-a-counter-value) 19 | - [Aggregate a value (e.g. sum of order revenue)](#aggregate-a-value-eg-sum-of-order-revenue) 20 | - [Hooks](#hooks) 21 | - [Manual counters](#manual-counters) 22 | - [Calculating a value from other counters](#calculating-a-value-from-other-counters) 23 | - [Defining a conditional counter](#defining-a-conditional-counter) 24 | - [Testing](#testing) 25 | - [Using Rspec](#using-rspec) 26 | - [In production](#in-production) 27 | - [TODO](#todo) 28 | - [Contributing](#contributing) 29 | - [License](#license) 30 | 31 | 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. 32 | 33 | 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): 34 | 35 | - 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 36 | - Counters are persisted as a ActiveRecord models (_not_ a column of an existing model) 37 | - Counters can also perform aggregation (e.g. sum of column values instead of counting rows) or be calculated from other counters 38 | - 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. 39 | - Incrementing counters can be safely performed in a background job via a change event/deferred reconciliation pattern (coming in a future iteration) 40 | 41 | ## Usage 42 | 43 | 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. 44 | 45 | ## Installation 46 | 47 | Add this line to your application's Gemfile: 48 | 49 | ```ruby 50 | gem 'counterwise', require: 'counter' 51 | ``` 52 | 53 | And then execute: 54 | 55 | ```bash 56 | $ bundle 57 | ``` 58 | 59 | Install the model migrations: 60 | 61 | ```bash 62 | $ rails counter:install:migrations 63 | ``` 64 | 65 | ## Main concepts 66 | 67 | ![](docs/data_model.png) 68 | 69 | `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… 70 | 71 | `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` 72 | 73 | ## Basic usage 74 | ### Define a counter 75 | 76 | Counters are defined in a seperate class using a small DSL. 77 | 78 | Given a `Store` with many `Order`s, it would be defined as… 79 | 80 | ```ruby 81 | class OrderCounter < Counter::Definition 82 | count :orders 83 | end 84 | 85 | class Store < ApplicationRecord 86 | include Counter::Counters 87 | 88 | has_many :orders 89 | counter OrderCounter 90 | end 91 | ``` 92 | 93 | First we define the counter class itself using `count` to specify the association we're counting, then "attach" it to the parent Store model. 94 | 95 | By default, the counter will be available as `_counter`, e.g. `store.orders_counter`. To customise this, use the `as` method: 96 | 97 | ```ruby 98 | class OrderCounter < Counter::Definition 99 | include Counter::Counters 100 | count :orders 101 | as :total_orders 102 | end 103 | 104 | store.total_orders 105 | ``` 106 | 107 | The counter's value will be stored as a `Counter::Value` with the name prefixed by the model name. e.g. `store-total_orders` 108 | 109 | ### Access counter values 110 | 111 | Since counters are represented as objects, you need to call `value` on them to retrieve the count. 112 | 113 | ```ruby 114 | store.total_orders #=> Counter::Value 115 | store.total_orders.value #=> 200 116 | ``` 117 | 118 | ### Recalculate a counter 119 | 120 | 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. 121 | 122 | You could refresh a store's revenue stats with: 123 | 124 | ```ruby 125 | store.order_revenue.recalc! 126 | ``` 127 | 128 | 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. 129 | 130 | ### Reset a counter 131 | 132 | You can also reset a counter by calling `reset`. 133 | 134 | ```ruby 135 | store.order_revenue.reset 136 | ``` 137 | 138 | Since counters are ActiveRecord objects, you could also reset them using: 139 | 140 | ```ruby 141 | Counter::Value.update value: 0 142 | ``` 143 | 144 | ### Verify a counter 145 | 146 | You might like to check if a counter is correct 147 | 148 | ```ruby 149 | store.product_revenue.correct? #=> false 150 | ``` 151 | 152 | 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!`: 153 | 154 | ```ruby 155 | store.product_revenue #=>200 156 | store.product_revenue.reset! 157 | store.product_revenue #=>0 158 | store.product_revenue.correct? #=> false 159 | store.product_revenue.correct! #=> false 160 | store.product_revenue #=>200 161 | ``` 162 | 163 | ## Advanced usage 164 | 165 | ### Sort or filter parent models by a counter value 166 | 167 | 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. 168 | 169 | ```ruby 170 | Customer.order_by_counter TotalRevenueCounter => :desc 171 | 172 | # You can sort by multiple counters or mix counters and model attributes 173 | Customer.order_by_counter TotalRevenueCounter => :desc, name: :asc 174 | ``` 175 | 176 | 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. 177 | 178 | ```ruby 179 | Customer.with_counter_data_from(TotalRevenueCounter).where("total_revenue_data > 1000") 180 | ``` 181 | 182 | These methods pull in the counter data itself but don't include the counter instances themselves. To do this, call 183 | 184 | ```ruby 185 | customers = Customer.with_counters TotalRevenueCounter 186 | # Since the counters are now preloaded, this avoids an N+1 query 187 | customers.each &:total_revenue 188 | ``` 189 | 190 | ### Aggregate a value (e.g. sum of order revenue) 191 | 192 | Sometimes you don'y want to count the number of orders but instead sum the value of those orders.. 193 | 194 | Given an ActiveRecord model `Order`, we can count a storefront's revenue like so 195 | 196 | ```ruby 197 | class Store < ApplicationRecord 198 | include Counter::Counters 199 | 200 | counter OrderRevenue 201 | end 202 | ``` 203 | 204 | Define the counter like so 205 | 206 | ```ruby 207 | class OrderRevenue < Counter::Definition 208 | count :orders 209 | sum :total_price 210 | end 211 | ``` 212 | 213 | and access it like 214 | 215 | ```ruby 216 | store.orders.create total_price: 100 217 | store.orders.create total_price: 100 218 | store.order_revenue.value #=> 200 219 | ``` 220 | 221 | ### Hooks 222 | 223 | 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. 224 | 225 | ```ruby 226 | class OrderRevenueCounter < Counter::Definition 227 | count :orders, as: :order_revenue 228 | sum :price 229 | 230 | after_change :send_congratulations_email 231 | 232 | # Only send an email when they cross $1000 233 | def send_congratulations_email counter, old_value, new_value 234 | return unless old_value < 1000 && new_value >= 1000 235 | send_email "Congratulations! You've made #{to} dollars!" 236 | end 237 | end 238 | ``` 239 | 240 | ### Manual counters 241 | 242 | 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. 243 | 244 | Manual counters just need a name 245 | 246 | ```ruby 247 | class TotalOrderCounter < Counter::Definition 248 | as "total_orders" 249 | end 250 | 251 | TotalOrderCounter.counter.value #=> 5 252 | TotalOrderCounter.counter.increment! #=> 6 253 | ``` 254 | 255 | ### Calculating a value from other counters 256 | 257 | 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. 258 | 259 | ```ruby 260 | class ConversionRateCounter < Counter::Definition 261 | count nil, as: "conversion_rate" 262 | 263 | calculated_from VisitsCounter, OrdersCounter do |visits, orders| 264 | (orders.value.to_f / visits.value) * 100 265 | end 266 | end 267 | ``` 268 | 269 | 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). 270 | 271 | ### Defining a conditional counter 272 | 273 | Conditional counters allow you to count a subset of an association, like just the premium product with a price >= 1000. 274 | 275 | ```ruby 276 | class Product < ApplicationRecord 277 | include Counter::Counters 278 | include Counter::Changable 279 | 280 | belongs_to :user 281 | 282 | scope :premium, -> { where("price >= 1000") } 283 | 284 | def premium? 285 | price >= 1000 286 | end 287 | end 288 | ``` 289 | 290 | 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. 291 | 292 | ```ruby 293 | class PremiumProductCounter < Counter::Definition 294 | # Define the association we're counting 295 | count :premium_products 296 | 297 | on :create do 298 | increment_if ->(product) { product.premium? } 299 | end 300 | 301 | on :delete do 302 | decrement_if ->(product) { product.premium? } 303 | end 304 | 305 | on :update do 306 | increment_if ->(product) { 307 | product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 } 308 | } 309 | 310 | decrement_if ->(product) { 311 | product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 } 312 | } 313 | end 314 | end 315 | ``` 316 | 317 | There is a lot going on here! 318 | 319 | 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. 320 | 321 | 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. 322 | 323 | `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. 324 | 325 | 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) 326 | 327 | 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). 328 | 329 | 330 | ## Testing 331 | 332 | ### Using Rspec 333 | 334 | If you use RSpec, you can include `Counter::RSpecMatchers` on your helpers and test your counter definitions. 335 | 336 | ```ruby 337 | require "counter/rspec/matchers" 338 | 339 | RSpec.configure do |config| 340 | config.include Counter::RSpecMatchers, type: :counter 341 | end 342 | ``` 343 | 344 | Now you can test your counter definitions like so: 345 | 346 | ```ruby 347 | require "rails_helper" 348 | 349 | RSpec.describe PremiumProductCounter, type: :counter do 350 | let(:store) { create(:store) } 351 | 352 | describe "on :create" do 353 | context "when the product is premium" do 354 | it "increments the counter" do 355 | expect { create(:product, :premium, store: store) }.to increment_counter_for(described_class, store) 356 | end 357 | end 358 | 359 | context "when the product is not premium" do 360 | it "doesn't increment the counter" do 361 | expect { create(:product, store: store) }.not_to increment_counter_for(described_class, store) 362 | end 363 | end 364 | end 365 | 366 | describe "on :delete" do 367 | context "when the product is premium" do 368 | it "decrements the counter" do 369 | expect { create(:product, :premium, store: store) }.to decrement_counter_for(described_class, store) 370 | end 371 | end 372 | 373 | context "when the product is not premium" do 374 | it "doesn't decrement the counter" do 375 | expect { create(:product, store: store) }.not_to decrement_counter_for(described_class, store) 376 | end 377 | end 378 | end 379 | end 380 | ``` 381 | 382 | ### In production 383 | 384 | > test in prod or live a lie — Charity Majors 385 | 386 | 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. 387 | 388 | A simple approach would be: 389 | 390 | ```ruby 391 | Counter::Value.all.each &:correct! 392 | ``` 393 | 394 | 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 395 | 396 | ```ruby 397 | Counter::Value.sample_and_verify samples: 1000, verbose: true, on_error: :correct 398 | ``` 399 | 400 | Options: 401 | 402 | - 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 403 | - samples — the number of counters to sample. Default: 1000 404 | - verbose — print out the counter details and whether it was correct. Default: true 405 | - 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 406 | 407 | --- 408 | 409 | ## TODO 410 | 411 | See the asociated project in Github but roughly I'm thinking: 412 | - Implement the background job pattern for incrementing counters 413 | - 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? 414 | - 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? 415 | - 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. 416 | - Actually start running this in production for basic use cases 417 | 418 | ## Contributing 419 | 420 | 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. 421 | 422 | I'm unlikely to entertain suport for older Ruby or Rails versions, or databases other than Postgres. 423 | 424 | ## License 425 | 426 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 427 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/assets/config/counter_manifest.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/assets/config/counter_manifest.js -------------------------------------------------------------------------------- /app/assets/images/counter/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/assets/images/counter/.keep -------------------------------------------------------------------------------- /app/assets/stylesheets/counter/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/assets/stylesheets/counter/.keep -------------------------------------------------------------------------------- /app/controllers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/controllers/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/helpers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/helpers/.keep -------------------------------------------------------------------------------- /app/jobs/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/jobs/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/mailers/.keep -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/models/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 true unless definition.conditional? 15 | 16 | conditions = definition.conditions[on] 17 | return true unless conditions 18 | 19 | conditions.any? do |conditions| 20 | if increment 21 | conditions.increment_conditions.any? do |condition| 22 | condition.call(item) 23 | end 24 | else 25 | conditions.decrement_conditions.any? do |condition| 26 | condition.call(item) 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /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/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/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 | return unless increment?(item, :create) 23 | 24 | increment! by: increment_from_item(item) 25 | end 26 | 27 | def remove_item item 28 | return unless decrement?(item, :delete) 29 | 30 | decrement! by: increment_from_item(item) 31 | end 32 | 33 | def update_item item 34 | if increment?(item, :update) 35 | increment! by: increment_from_item(item) 36 | end 37 | 38 | if decrement?(item, :update) 39 | decrement! by: increment_from_item(item) 40 | end 41 | end 42 | 43 | # How much should we increment the counter 44 | def increment_from_item item 45 | 1 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /app/models/concerns/counter/recalculatable.rb: -------------------------------------------------------------------------------- 1 | module Counter::Recalculatable 2 | extend ActiveSupport::Concern 3 | 4 | def recalc! 5 | if definition.calculated? 6 | calculate! 7 | elsif definition.manual? 8 | raise Counter::Error.new("Can't recalculate a manual counter") 9 | else 10 | with_lock do 11 | new_value = definition.sum? ? sum_by_sql : count_by_sql 12 | update! value: new_value 13 | end 14 | end 15 | end 16 | 17 | def count_by_sql 18 | recalc_scope.count 19 | end 20 | 21 | def sum_by_sql 22 | recalc_scope.sum(definition.column_to_count) 23 | end 24 | 25 | # use this scope when recalculating the value 26 | def recalc_scope 27 | parent.association(definition.association_name).scope 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/views/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/app/views/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /bin/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | $: << File.expand_path("../test", __dir__) 3 | 4 | require "bundler/setup" 5 | require "rails/plugin/test" 6 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :counters, only: [:update, :destroy] 3 | end 4 | -------------------------------------------------------------------------------- /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.metadata["homepage_uri"] = spec.homepage 14 | spec.metadata["source_code_uri"] = "https://github.com/podia/counter" 15 | spec.metadata["changelog_uri"] = "https://github.com/podia/counter/CHANGELOG.md" 16 | 17 | spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 18 | 19 | spec.add_dependency "rails", ">= 7" 20 | end 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /docs/data_model.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/docs/data_model.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 38 | # Is this a counter which sums a column? 39 | def sum? 40 | column_to_count.present? 41 | end 42 | 43 | # Is this a global counter? i.e., not attached to a model 44 | def global? 45 | model.nil? 46 | end 47 | 48 | # Is this counter conditional? 49 | def conditional? 50 | @conditional 51 | end 52 | 53 | # Is this counter calculated from other counters? 54 | def calculated? 55 | !@calculated_from.nil? 56 | end 57 | 58 | # Is this a manual counter? 59 | # Manual counters are not automatically updated from an association 60 | # or calculated from other counters 61 | def manual? 62 | association_name.nil? && !calculated? 63 | end 64 | 65 | # for global counter instances to find their definition 66 | def self.find_definition name 67 | Counter::Definition.instance.global_counters.find { |c| c.name == name } 68 | end 69 | 70 | # Access the counter value for global counters 71 | def self.counter 72 | 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? 73 | 74 | Counter::Value.find_counter self 75 | end 76 | 77 | # What we record in Counter::Value#name 78 | def record_name 79 | return name if global? 80 | return "#{model.name.underscore}-#{association_name}" if association_name.present? 81 | return "#{model.name.underscore}-#{name}" 82 | end 83 | 84 | def conditions 85 | @conditions ||= {} 86 | @conditions 87 | end 88 | 89 | def global_counters 90 | @global_counters ||= [] 91 | @global_counters 92 | end 93 | 94 | def counter_hooks 95 | @counter_hooks ||= [] 96 | @counter_hooks 97 | end 98 | 99 | def dependent_counters 100 | @dependent_counters ||= [] 101 | @dependent_counters 102 | end 103 | 104 | # Set the association we're counting 105 | def self.count association_name, as: "#{association_name}_counter" 106 | instance.association_name = association_name 107 | instance.name = as.to_s 108 | # How the counter can be accessed e.g. counter.products_counter 109 | instance.method_name = as.to_s 110 | end 111 | 112 | def self.global 113 | Counter::Definition.instance.global_counters << instance 114 | end 115 | 116 | def self.calculated_from *dependent_counters, &block 117 | instance.dependent_counters = dependent_counters 118 | instance.calculated_from = block 119 | 120 | dependent_counters.each do |dependent_counter| 121 | # Install after_change hooks on the dependent counters 122 | dependent_counter.after_change :update_calculated_counters 123 | dependent_counter.define_method :update_calculated_counters do |counter, _old_value, _new_value| 124 | # Fetch all the counters which depend on this one 125 | calculated_counters = counter.parent.class.counter_configs.select { |c| 126 | c.dependent_counters.include?(counter.definition.class) 127 | } 128 | 129 | calculated_counters = calculated_counters.map { |c| counter.parent.counters.find_or_create_counter!(c) } 130 | # calculate the new values 131 | calculated_counters.each(&:calculate!) 132 | end 133 | end 134 | end 135 | 136 | # Set the name of the counter 137 | def self.as name 138 | instance.name = name.to_s 139 | instance.method_name = name.to_s 140 | end 141 | 142 | # Get the name of the association we're counting 143 | def self.association_name 144 | instance.association_name 145 | end 146 | 147 | # Set the column we're summing. Leave blank to count the number of items 148 | def self.sum column_name 149 | instance.column_to_count = column_name 150 | end 151 | 152 | # Define a conditional filter 153 | def self.on action, &block 154 | instance.conditional = true 155 | 156 | conditions = Counter::Conditions.new 157 | conditions.instance_eval(&block) 158 | 159 | instance.conditions[action] ||= [] 160 | instance.conditions[action] << conditions 161 | end 162 | 163 | def self.after_change block 164 | instance.counter_hooks << block 165 | end 166 | end 167 | -------------------------------------------------------------------------------- /lib/counter/engine.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Counter 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/counter/error.rb: -------------------------------------------------------------------------------- 1 | class Counter::Error < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /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 | counter = parent_model.counters.find_or_create_counter!(counter_definition) 36 | yield counter if counter 37 | end 38 | end 39 | end 40 | 41 | class_methods do 42 | def counted_by 43 | @counted_by 44 | end 45 | 46 | def add_counted_by config 47 | @counted_by ||= [] 48 | @counted_by << config 49 | end 50 | 51 | def inherited subclass 52 | super 53 | @counted_by.each { |c| subclass.add_counted_by c } 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /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 | scope :with_counter_data_from, ->(*counter_classes) { 61 | subqueries = ["#{table_name}.*"] 62 | counter_classes.each do |counter_class| 63 | sql = Counter::Value.select("value") 64 | .where("parent_id = #{table_name}.id AND parent_type = '#{name}' AND name = '#{counter_class.instance.record_name}'").to_sql 65 | subqueries << "(#{sql}) AS #{counter_class.instance.name}_data" 66 | end 67 | select(subqueries) 68 | } 69 | 70 | # Expects a hash of counter classes and directions, like so: 71 | # order_by_counter ProductCounter => :desc, PremiumProductCounter => :asc 72 | scope :order_by_counter, ->(order_hash) { 73 | counter_classes = order_hash.keys.select { |counter_class| 74 | counter_class.is_a?(Class) && 75 | counter_class.ancestors.include?(Counter::Definition) 76 | } 77 | order_params = {} 78 | order_hash.map do |counter_class, direction| 79 | if counter_class.is_a?(String) || counter_class.is_a?(Symbol) 80 | order_params[counter_class] = direction 81 | elsif counter_class.ancestors.include?(Counter::Definition) 82 | order_params["#{counter_class.instance.name}_data"] = direction 83 | end 84 | end 85 | with_counter_data_from(*counter_classes).order(order_params) 86 | } 87 | 88 | scope :with_counters, -> { includes(:counters) } 89 | 90 | define_method definition.method_name do 91 | counters.find_or_create_counter!(definition) 92 | end 93 | 94 | @counter_configs << definition unless @counter_configs.include?(definition) 95 | 96 | association_name = definition.association_name 97 | if association_name.present? 98 | # Find the association on this model 99 | association_reflection = reflect_on_association(association_name) 100 | raise Counter::Error.new("#{association_name} does not exist #{self.name}") if association_reflection.nil? 101 | 102 | # Find the association classes 103 | association_class = association_reflection.class_name.constantize 104 | inverse_association = association_reflection.inverse_of 105 | raise Counter::Error.new("#{association_name} must have an inverse_of specified to be used in #{definition_class.name}") if inverse_association.nil? 106 | 107 | # Add the after_commit hook to the association's class 108 | association_class.include Counter::Countable 109 | 110 | # Update the definition with the association class and inverse association 111 | # gathered from the reflection 112 | definition.inverse_association = inverse_association.name 113 | definition.countable_model = association_class 114 | 115 | # Provide the Countable class with details about where it's counted 116 | association_class.add_counted_by definition 117 | end 118 | end 119 | end 120 | 121 | # Returns a list of Counter::Definitions 122 | def counter_configs 123 | @counter_configs || [] 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/counter/railtie.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | class Railtie < ::Rails::Railtie 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/counter/version.rb: -------------------------------------------------------------------------------- 1 | module Counter 2 | VERSION = "0.1.5" 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/counter_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :counter do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /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/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/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/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /test/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/app/assets/images/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | end 3 | -------------------------------------------------------------------------------- /test/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /test/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /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/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/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/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /test/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/app/models/concerns/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /test/dummy/app/models/global_order_counter.rb: -------------------------------------------------------------------------------- 1 | class GlobalOrderCounter < Counter::Definition 2 | global 3 | as "total_orders" 4 | end 5 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /test/dummy/app/models/orders_counter.rb: -------------------------------------------------------------------------------- 1 | class OrdersCounter < Counter::Definition 2 | count :orders 3 | end 4 | -------------------------------------------------------------------------------- /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/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 | 19 | scope :premium, -> { where("price >= 1000") } 20 | counter OrderRevenueCounter 21 | 22 | def premium? 23 | (price || 0) >= 1000 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/dummy/app/models/product_counter.rb: -------------------------------------------------------------------------------- 1 | class ProductCounter < Counter::Definition 2 | count :products 3 | end 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 16 | counter ProductCounter, PremiumProductCounter, OrdersCounter, VisitsCounter 17 | counter ConversionRateCounter 18 | end 19 | -------------------------------------------------------------------------------- /test/dummy/app/models/visits_counter.rb: -------------------------------------------------------------------------------- 1 | class VisitsCounter < Counter::Definition 2 | as "visits_counter" 3 | end 4 | -------------------------------------------------------------------------------- /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/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /test/dummy/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /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/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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[7.0].define(version: 2023_07_10_225537) 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 "orders", force: :cascade do |t| 27 | t.integer "user_id", null: false 28 | t.integer "product_id", null: false 29 | t.integer "price" 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.index ["product_id"], name: "index_orders_on_product_id" 33 | t.index ["user_id"], name: "index_orders_on_user_id" 34 | end 35 | 36 | create_table "products", force: :cascade do |t| 37 | t.integer "user_id", null: false 38 | t.string "name" 39 | t.integer "price" 40 | t.datetime "created_at", null: false 41 | t.datetime "updated_at", null: false 42 | t.index ["user_id"], name: "index_products_on_user_id" 43 | end 44 | 45 | create_table "users", force: :cascade do |t| 46 | t.datetime "created_at", null: false 47 | t.datetime "updated_at", null: false 48 | end 49 | 50 | add_foreign_key "orders", "products" 51 | add_foreign_key "orders", "users" 52 | add_foreign_key "products", "users" 53 | end 54 | -------------------------------------------------------------------------------- /test/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/lib/assets/.keep -------------------------------------------------------------------------------- /test/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/log/.keep -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /test/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/public/apple-touch-icon.png -------------------------------------------------------------------------------- /test/dummy/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/podia/counter/baec9cc4991d6d31b1faf180752b3abf37bf1d38/test/dummy/public/favicon.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | end 32 | -------------------------------------------------------------------------------- /test/integration/counters_test.rb: -------------------------------------------------------------------------------- 1 | require "test_helper" 2 | 3 | class CountersTest < ActiveSupport::TestCase 4 | end 5 | -------------------------------------------------------------------------------- /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 5, 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 | end 172 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | end 38 | -------------------------------------------------------------------------------- /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 | end 41 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------