counterwise 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +313 -0
  4. data/Rakefile +18 -0
  5. data/app/assets/config/counter_manifest.js +0 -0
  6. data/app/controllers/counters_controller.rb +17 -0
  7. data/app/jobs/counter/reconciliation_job.rb +12 -0
  8. data/app/models/concerns/counter/Xhierarchical.rb +23 -0
  9. data/app/models/concerns/counter/changable.rb +42 -0
  10. data/app/models/concerns/counter/conditional.rb +32 -0
  11. data/app/models/concerns/counter/definable.rb +17 -0
  12. data/app/models/concerns/counter/hooks.rb +17 -0
  13. data/app/models/concerns/counter/increment.rb +48 -0
  14. data/app/models/concerns/counter/recalculatable.rb +24 -0
  15. data/app/models/concerns/counter/reset.rb +11 -0
  16. data/app/models/concerns/counter/sidekiq_reconciliation.rb +60 -0
  17. data/app/models/concerns/counter/summable.rb +15 -0
  18. data/app/models/concerns/counter/verifyable.rb +14 -0
  19. data/app/models/counter/change.rb +23 -0
  20. data/app/models/counter/value.rb +47 -0
  21. data/config/routes.rb +3 -0
  22. data/db/migrate/20210705154113_create_counter_values.rb +11 -0
  23. data/db/migrate/20210709211056_create_counter_changes.rb +11 -0
  24. data/db/migrate/20210731224504_add_unique_index_to_counter_values.rb +6 -0
  25. data/lib/counter/any.rb +6 -0
  26. data/lib/counter/conditions.rb +16 -0
  27. data/lib/counter/definition.rb +128 -0
  28. data/lib/counter/engine.rb +5 -0
  29. data/lib/counter/error.rb +2 -0
  30. data/lib/counter/integration/countable.rb +48 -0
  31. data/lib/counter/integration/counters.rb +94 -0
  32. data/lib/counter/railtie.rb +4 -0
  33. data/lib/counter/version.rb +3 -0
  34. data/lib/counter.rb +12 -0
  35. data/lib/tasks/counter_tasks.rake +4 -0
  36. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bf86231a128b10c3db267b181bec88ba90bc1cfc07039629a425254cc7d3c612
4
+ data.tar.gz: 27bc3b7c1140653a2588e48c960c551bdb1a455dee92eb5c276c3e0c9eb55891
5
+ SHA512:
6
+ metadata.gz: 9f549a07032d2492cc0d4470ebd1e7a5de736f4dceeca90c883b29fb85d60920fc9274c5e0c822f4e07e27768817ccbbe1b9746c2d29e197fa7af1d7f69272f0
7
+ data.tar.gz: a777ea9f55d06e37e1ecb35af9a0526f9fa7cd51304f8549bb821b0f527020a78fd843c11d31f5110e08e015c43a8856c1f0456571c13ccfad4f13ed81b91eff
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
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.
data/README.md ADDED
@@ -0,0 +1,313 @@
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
+ - [Main concepts](#main-concepts)
9
+ - [Defining a counter](#defining-a-counter)
10
+ - [Accessing counter values](#accessing-counter-values)
11
+ - [Anonymous counters](#anonymous-counters)
12
+ - [Defining a conditional counter](#defining-a-conditional-counter)
13
+ - [Aggregating a value (e.g. sum of order revenue)](#aggregating-a-value-eg-sum-of-order-revenue)
14
+ - [Recalculating a counter](#recalculating-a-counter)
15
+ - [Reset a counter](#reset-a-counter)
16
+ - [Verify a counter](#verify-a-counter)
17
+ - [Hooks](#hooks)
18
+ - [TODO](#todo)
19
+ - [Usage](#usage)
20
+ - [Installation](#installation)
21
+ - [Contributing](#contributing)
22
+ - [License](#license)
23
+
24
+ By the time you need Rails counter_caches you probably have other needs too. You probably want to sum column values and you probably have enough throughput that updating a single column value will cause lock contention problems too.
25
+
26
+ 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):
27
+
28
+ - 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
29
+ - Counters are persisted as a ActiveRecord models (_not_ a column of an existing model)
30
+ - Incrementing counters can be safely performed in a background job via a change event/deferred reconciliation pattern
31
+ - 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.
32
+ - Counters can also perform aggregation (e.g. sum of column values instead of counting rows)
33
+
34
+
35
+ ## Main concepts
36
+
37
+ ![](docs/data_model.png)
38
+
39
+ `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…
40
+
41
+ `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`
42
+
43
+ `Counter::Change` is a temporary record that records a change to a counter. Instead of updating a counter directly, which requires obtaining a lock on it to perform it safely and atomically, a new `Change` event is inserted into the table. On regular intervals, the `Counter::Value` is updated by incrementing the value by the sum of all outstanding changes. This requires much less frequent locks at the expense of eventual consistency.
44
+
45
+ For example, you might have many background jobs running concurrently, inserting hundreds/thousands of rows. The would not need to fight for a lock to update the counter and would only need to insert Counter::Change rows. The counter would then be updated, in a single operation, by summing all the persisted change values.
46
+
47
+ Basically updating a counter value requires this SQL:
48
+
49
+ ```sql
50
+ UPDATE counter_values
51
+ -- Update the counter with the sum of pending changes
52
+ SET value = value + changes.sum
53
+ FROM (
54
+ -- Find the pending changes for the counter
55
+ SELECT sum(value) as sum
56
+ FROM counter_changes
57
+ WHERE counter_id = 100
58
+ ) as changes
59
+ WHERE id = 100
60
+ ```
61
+
62
+ Or even reconcile all pending counters in a single statement:
63
+
64
+ ```sql
65
+ UPDATE counter_values
66
+ SET value = value + changes.sum
67
+ FROM (
68
+ SELECT sum(value)
69
+ FROM counter_changes
70
+ GROUP BY counter_id
71
+ ) as changes
72
+ WHERE counters.id = counter_id
73
+ ```
74
+
75
+ ## Defining a counter
76
+
77
+ Counters are defined in a seperate class using a small DSL.
78
+
79
+ Given a `Store` with many `Order`s, it would be defined as…
80
+
81
+ ```ruby
82
+ class OrderCounter < Counter::Definition
83
+ count :orders
84
+ end
85
+
86
+ class Store < ApplicationRecord
87
+ include Counter::Counters
88
+
89
+ has_many :orders
90
+ counter OrderCounter
91
+ end
92
+ ```
93
+
94
+ First we define the counter class itself using `count` to specify the association we're counting, then "attach" it to the parent Store model.
95
+
96
+ By default, the counter will be available as `<association>_counter`, e.g. `store.orders_counter`. To customise this, pass a `as` parameter:
97
+
98
+ ```ruby
99
+ class OrderCounter < Counter::Definition
100
+ include Counter::Counters
101
+ count :orders, as: :total_orders
102
+ end
103
+
104
+ store.total_orders
105
+ ```
106
+
107
+ The counter's value with be stored as a `Counter::Value` with the name prefixed by the model name. e.g. `store_total_orders`
108
+
109
+ ## Accessing 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
+ ## Anonymous counters
119
+
120
+ 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 global counter that you can increment.
121
+
122
+ ```ruby
123
+ class GlobalOrderCounter < Counter::Definition
124
+ global :my_custom_counter_name
125
+ end
126
+
127
+ GlobalOrderCounter.counter.value #=> 5
128
+ GlobalOrderCounter.counter.increment! #=> 6
129
+ ```
130
+
131
+ ## Defining a conditional counter
132
+
133
+ Consider this model that we'd like to count but we don't want to count all products, just the premium ones with a price >= 1000
134
+
135
+ ```ruby
136
+ class Product < ApplicationRecord
137
+ include Counter::Counters
138
+ include Counter::Changable
139
+
140
+ belongs_to :user
141
+
142
+ scope :premium, -> { where("price >= 1000") }
143
+
144
+ def premium?
145
+ price >= 1000
146
+ end
147
+ end
148
+ ```
149
+
150
+ Here's the counter to do that:
151
+
152
+ ```ruby
153
+ class PremiumProductCounter < Counter::Definition
154
+ # Define the association we're counting
155
+ count :premium_products
156
+
157
+ on :create do
158
+ increment_if ->(product) { product.premium? }
159
+ end
160
+
161
+ on :delete do
162
+ decrement_if ->(product) { product.premium? }
163
+ end
164
+
165
+ on :update do
166
+ increment_if ->(product) {
167
+ product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 }
168
+ }
169
+
170
+ decrement_if ->(product) {
171
+ product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 }
172
+ }
173
+ end
174
+ end
175
+ ```
176
+
177
+ There is a lot going on here!
178
+
179
+ 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.
180
+
181
+ 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.
182
+
183
+ `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.
184
+
185
+ 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)
186
+
187
+ 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).
188
+
189
+ ## Aggregating a value (e.g. sum of order revenue)
190
+
191
+ Given an ActiveRecord model `Order`, we can count a storefront's revenue like so
192
+
193
+ ```ruby
194
+ class Store < ApplicationRecord
195
+ include Counter::Counters
196
+
197
+ counter OrderRevenue
198
+ end
199
+ ```
200
+
201
+ Define the counter like so
202
+
203
+ ```ruby
204
+ class OrderRevenue < Counter::Definition
205
+ count :orders
206
+ sum :total_price
207
+ end
208
+ ```
209
+
210
+ and access it like
211
+
212
+ ```ruby
213
+ store.orders.create total_price: 100
214
+ store.orders.create total_price: 100
215
+ store.order_revenue.value #=> 200
216
+ ```
217
+
218
+ ## Recalculating a counter
219
+
220
+ 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.
221
+
222
+ You could refresh a store's revenue stats with:
223
+
224
+ ```ruby
225
+ store.order_revenue.recalc!
226
+ ```
227
+
228
+ 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.
229
+
230
+ ## Reset a counter
231
+
232
+ You can also reset a counter by calling `reset`. Since counters are ActiveRecord objects, you could also reset them using
233
+
234
+ ```ruby
235
+ store.order_revenue.reset
236
+ Counter::Value.update value: 0
237
+ ```
238
+
239
+ ## Verify a counter
240
+
241
+ You might like to check if a counter is correct
242
+
243
+ ```ruby
244
+ store.product_revenue.correct? #=> false
245
+ ```
246
+
247
+ 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!`:
248
+
249
+ ```ruby
250
+ store.product_revenue #=>200
251
+ store.product_revenue.reset!
252
+ store.product_revenue #=>0
253
+ store.product_revenue.correct? #=> false
254
+ store.product_revenue.correct! #=> false
255
+ store.product_revenue #=>200
256
+ ```
257
+
258
+ ## Hooks
259
+
260
+ 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.
261
+
262
+ ```ruby
263
+ class OrderRevenueCounter < Counter::Definition
264
+ count :orders, as: :order_revenue
265
+ sum :price
266
+
267
+ after_change :send_congratulations_email
268
+
269
+ def send_congratulations_email counter, from, to
270
+ return unless from < 1000 && to >= 1000
271
+ send_email "Congratulations! You've made #{to} dollars!"
272
+ end
273
+ end
274
+ ```
275
+
276
+ ---
277
+
278
+ ## TODO
279
+
280
+ See the asociated project in Github but roughly I'm thinking:
281
+ - 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?
282
+ - 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?
283
+ - Can we support floating point values? Sounds useful but don't have a use case for it right now. Would they need to be a different ActiveRecord table?
284
+ - 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.
285
+ - Actually start running this in production for basic use cases
286
+
287
+ ## Usage
288
+ No one has used this in production yet.
289
+
290
+ You probably shouldn't right now unless you're the sort of person that checks if something is poisonous by licking it.
291
+
292
+ ## Installation
293
+ Add this line to your application's Gemfile:
294
+
295
+ ```ruby
296
+ gem 'counter'
297
+ ```
298
+
299
+ And then execute:
300
+ ```bash
301
+ $ bundle
302
+ ```
303
+
304
+ Install the model migrations:
305
+ ```bash
306
+ $ rails counter:install:migrations
307
+ ```
308
+
309
+ ## Contributing
310
+ Contribution directions go here.
311
+
312
+ ## License
313
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,18 @@
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
File without changes
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,23 @@
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
@@ -0,0 +1,42 @@
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 <something>
34
+ # return new_value == to if from.instance_of?(Any)
35
+ # # Check if the value change to <something>
36
+ # return old_value == from if to.instance_of?(Any)
37
+
38
+ # Check if the value change from <something> to <something>
39
+ from_condition && to_condition
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,17 @@
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
+ # Fetch the definition for this counter
8
+ def definition
9
+ if parent.nil?
10
+ # We don't have a parent, so we're a global counter
11
+ Counter::Definition.find_definition name
12
+ else
13
+ parent.class.counter_configs.find { |c| c.record_name == name }
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
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
@@ -0,0 +1,48 @@
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
@@ -0,0 +1,24 @@
1
+ module Counter::Recalculatable
2
+ extend ActiveSupport::Concern
3
+
4
+ ####################################################### Support for regenerating the counters
5
+ def recalc!
6
+ with_lock do
7
+ new_value = definition.sum? ? sum_by_sql : count_by_sql
8
+ update! value: new_value
9
+ end
10
+ end
11
+
12
+ def count_by_sql
13
+ recalc_scope.count
14
+ end
15
+
16
+ def sum_by_sql
17
+ recalc_scope.sum(definition.column_to_count)
18
+ end
19
+
20
+ # use this scope when recalculating the value
21
+ def recalc_scope
22
+ parent.association(definition.association_name).scope
23
+ end
24
+ end
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,14 @@
1
+ module Counter::Verifyable
2
+ extend ActiveSupport::Concern
3
+
4
+ def correct?
5
+ count_by_sql == value
6
+ end
7
+
8
+ def correct!
9
+ requires_recalculation = !correct?
10
+ recalc! if requires_recalculation
11
+
12
+ !requires_recalculation
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # == Schema Information
2
+ #
3
+ # Table name: counter_changes
4
+ #
5
+ # id :integer not null, primary key
6
+ # counter_value_id :integer indexed
7
+ # amount :integer
8
+ # processed_at :datetime indexed
9
+ # created_at :datetime not null
10
+ # updated_at :datetime not null
11
+ #
12
+ class Counter::Change < ApplicationRecord
13
+ def self.table_name_prefix
14
+ "counter_"
15
+ end
16
+
17
+ belongs_to :counter, class_name: "Counter::Value"
18
+ validates_numericality_of :amount
19
+
20
+ scope :pending, -> { where(reconciled_at: nil) }
21
+ scope :reconciled, -> { where.not(reconciled_at: nil) }
22
+ scope :purgable, -> { reconciled.where(processed_at: 7.days.ago..) }
23
+ end
@@ -0,0 +1,47 @@
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::Hierarchical
47
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ Rails.application.routes.draw do
2
+ resources :counters, only: [:update, :destroy]
3
+ end
@@ -0,0 +1,11 @@
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.integer :value, default: 0
6
+ t.references :parent, polymorphic: true
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ class CreateCounterChanges < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :counter_changes do |t|
4
+ t.references :counter_value, foreign_key: true
5
+ t.integer :amount
6
+ t.timestamp :processed_at, index: true
7
+
8
+ t.timestamps
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,6 @@
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
@@ -0,0 +1,6 @@
1
+ # Simple class to represent any value in the filters
2
+ require "singleton"
3
+
4
+ class Counter::Any
5
+ include Singleton
6
+ end
@@ -0,0 +1,16 @@
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
@@ -0,0 +1,128 @@
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
+ # filters: { # optional
9
+ # create: ->(product) { product.premium? }
10
+ # update: ->(product) { product.has_changed? :premium, to: :true }
11
+ # delete: ->(product) { product.premium? }
12
+ # }
13
+ # end
14
+ class Counter::Definition
15
+ include Singleton
16
+
17
+ # Attributes set by Counters#counter integration:
18
+ attr_accessor :association_name
19
+ # Set the model we're attached to (set by Counters#counter)
20
+ attr_accessor :model
21
+ # Set the thing we're counting (set by Counters#counter)
22
+ attr_accessor :countable_model
23
+ # Set the inverse association (i.e., from the products to the user)
24
+ attr_accessor :inverse_association
25
+ # When using sum, set the column we're summing
26
+ attr_accessor :column_to_count
27
+ # Test if we should count items using conditions
28
+ attr_writer :conditions
29
+ attr_writer :conditional
30
+ # Set the name of the counter (used as the method name)
31
+ attr_accessor :method_name
32
+ attr_accessor :name
33
+ # An array of all global counters
34
+ attr_writer :global_counters
35
+ # An array of Proc to run when the counter changes
36
+ attr_writer :counter_hooks
37
+ # An array of all global counters
38
+ attr_writer :global_counters
39
+
40
+ def sum?
41
+ column_to_count.present?
42
+ end
43
+
44
+ def global?
45
+ model.nil? && association_name.nil?
46
+ end
47
+
48
+ def conditional?
49
+ @conditional
50
+ end
51
+
52
+ # for global counter instances to find their definition
53
+ def self.find_definition name
54
+ Counter::Definition.instance.global_counters.find { |c| c.name == name }
55
+ end
56
+
57
+ # Access the counter value for global counters
58
+ def self.counter
59
+ 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?
60
+
61
+ Counter::Value.find_counter self
62
+ end
63
+
64
+ # What we record in Counter::Value#name
65
+ def record_name
66
+ return name if global?
67
+ "#{model.name.underscore}-#{association_name}"
68
+ end
69
+
70
+ def conditions
71
+ @conditions ||= {}
72
+ @conditions
73
+ end
74
+
75
+ def global_counters
76
+ @global_counters ||= []
77
+ @global_counters
78
+ end
79
+
80
+ def counter_hooks
81
+ @counter_hooks ||= []
82
+ @counter_hooks
83
+ end
84
+
85
+ def global_counters
86
+ @global_counters ||= []
87
+ @global_counters
88
+ end
89
+
90
+ # Set the association we're counting
91
+ def self.count association_name, as: "#{association_name}_counter"
92
+ instance.association_name = association_name
93
+ instance.name = as.to_s
94
+ # How the counter can be accessed e.g. counter.products_counter
95
+ instance.method_name = as.to_s
96
+ end
97
+
98
+ def self.global name = nil
99
+ name ||= name.underscore
100
+ instance.name = name.to_s
101
+ Counter::Definition.instance.global_counters << instance
102
+ end
103
+
104
+ # Get the name of the association we're counting
105
+ def self.association_name
106
+ instance.association_name
107
+ end
108
+
109
+ # Set the column we're summing. Leave blank to count the number of items
110
+ def self.sum column_name
111
+ instance.column_to_count = column_name
112
+ end
113
+
114
+ # Define a conditional filter
115
+ def self.on action, &block
116
+ instance.conditional = true
117
+
118
+ conditions = Counter::Conditions.new
119
+ conditions.instance_eval(&block)
120
+
121
+ instance.conditions[action] ||= []
122
+ instance.conditions[action] << conditions
123
+ end
124
+
125
+ def self.after_change block
126
+ instance.counter_hooks << block
127
+ end
128
+ end
@@ -0,0 +1,5 @@
1
+ module Counter
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace Counter
4
+ end
5
+ end
@@ -0,0 +1,2 @@
1
+ class Counter::Error < StandardError
2
+ end
@@ -0,0 +1,48 @@
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
+ parent_model = association(counter_definition.inverse_association)
30
+ .target
31
+ next unless parent_model
32
+ counter = parent_model.counters.find_or_create_counter!(counter_definition)
33
+ yield counter if counter
34
+ end
35
+ end
36
+ end
37
+
38
+ class_methods do
39
+ def counted_by
40
+ @counted_by
41
+ end
42
+
43
+ def add_counted_by config
44
+ @counted_by ||= []
45
+ @counted_by << config
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,94 @@
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
+ association_name = definition.association_name
59
+
60
+ # Find the association on this model
61
+ association_reflection = reflect_on_association(association_name)
62
+ # Find the association classes
63
+ association_class = association_reflection.class_name.constantize
64
+ inverse_association = association_reflection.inverse_of
65
+
66
+ raise Counter::Error.new("#{association_name} must have an inverse_of specified to be used in #{definition_class.name}") if inverse_association.nil?
67
+
68
+ # Add the after_commit hook to the association's class
69
+ association_class.include Counter::Countable
70
+ # association_class.include Counter::Changed
71
+
72
+ # Update the definition with the association class and inverse association
73
+ # gathered from the reflection
74
+ definition.model = self
75
+ definition.inverse_association = inverse_association.name
76
+ definition.countable_model = association_class
77
+
78
+ define_method definition.method_name do
79
+ counters.find_or_create_counter!(definition)
80
+ end
81
+
82
+ # Provide the Countable class with details about where it's counted
83
+
84
+ @counter_configs << definition
85
+ association_class.add_counted_by definition
86
+ end
87
+ end
88
+
89
+ # Returns a list of Counter::Definitions
90
+ def counter_configs
91
+ @counter_configs || []
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,4 @@
1
+ module Counter
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,3 @@
1
+ module Counter
2
+ VERSION = "0.1.0"
3
+ end
data/lib/counter.rb ADDED
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :counter do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,108 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: counterwise
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Jamie Lawrence
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-07-28 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '7'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '7'
27
+ - !ruby/object:Gem::Dependency
28
+ name: standard
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ description: Counting and aggregation library for Rails.
42
+ email:
43
+ - jamie@ideasasylum.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - MIT-LICENSE
49
+ - README.md
50
+ - Rakefile
51
+ - app/assets/config/counter_manifest.js
52
+ - app/controllers/counters_controller.rb
53
+ - app/jobs/counter/reconciliation_job.rb
54
+ - app/models/concerns/counter/Xhierarchical.rb
55
+ - app/models/concerns/counter/changable.rb
56
+ - app/models/concerns/counter/conditional.rb
57
+ - app/models/concerns/counter/definable.rb
58
+ - app/models/concerns/counter/hooks.rb
59
+ - app/models/concerns/counter/increment.rb
60
+ - app/models/concerns/counter/recalculatable.rb
61
+ - app/models/concerns/counter/reset.rb
62
+ - app/models/concerns/counter/sidekiq_reconciliation.rb
63
+ - app/models/concerns/counter/summable.rb
64
+ - app/models/concerns/counter/verifyable.rb
65
+ - app/models/counter/change.rb
66
+ - app/models/counter/value.rb
67
+ - config/routes.rb
68
+ - db/migrate/20210705154113_create_counter_values.rb
69
+ - db/migrate/20210709211056_create_counter_changes.rb
70
+ - db/migrate/20210731224504_add_unique_index_to_counter_values.rb
71
+ - lib/counter.rb
72
+ - lib/counter/any.rb
73
+ - lib/counter/conditions.rb
74
+ - lib/counter/definition.rb
75
+ - lib/counter/engine.rb
76
+ - lib/counter/error.rb
77
+ - lib/counter/integration/countable.rb
78
+ - lib/counter/integration/counters.rb
79
+ - lib/counter/railtie.rb
80
+ - lib/counter/version.rb
81
+ - lib/tasks/counter_tasks.rake
82
+ homepage: https://github.com/podia/counter
83
+ licenses:
84
+ - MIT
85
+ metadata:
86
+ homepage_uri: https://github.com/podia/counter
87
+ source_code_uri: https://github.com/podia/counter
88
+ changelog_uri: https://github.com/podia/counter/CHANGELOG.md
89
+ post_install_message:
90
+ rdoc_options: []
91
+ require_paths:
92
+ - lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ required_rubygems_version: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ requirements: []
104
+ rubygems_version: 3.3.7
105
+ signing_key:
106
+ specification_version: 4
107
+ summary: Counters and the counting counters that count them
108
+ test_files: []