counterwise 0.1.1 → 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c53c61fffe70d6bed84b2d5e5da5349b4a4285dbd7c19a8523721c449dcdbcc
4
- data.tar.gz: 23e702c8cc478833aa555ad28f4456912420036063bf53d30c35d61b426cbff9
3
+ metadata.gz: ac4f723a8cdebb397ea3c1035ee728599ce308ebe36fc5aaa9fb6afeff09474e
4
+ data.tar.gz: 5efc1f7fdbde64eab27962e0b37355ae16f8293c8173d101e85da97b81679dc7
5
5
  SHA512:
6
- metadata.gz: 3dc224b1948220028726742754a51b4523147f86de67bac047a3159a0760782d68722ec5afcbd017ad5b5d3434430a6c8db960906360dae858f484f43177b8c6
7
- data.tar.gz: 8ba0623f2cea3a11c538dbf10f8ac3cb0ebd49400b92a0617c630b62c297e34fefd6d6298a243f8615caeb238d47cffce4d5f1dd79e7701c01957737d958fb17
6
+ metadata.gz: 48b91a565a82de0da5dbdf3e6637721ae05ad4b5bea9e0b8c31366932e6b30f6460e526911a458ceda815910d1031046b2413eb0668207cd001c18afdb030002
7
+ data.tar.gz: 5d6fd86b0ab23cd378ed374c6bf91aab877626038f7c35acecd2b123777fb646340a4070e61e02cbbf98ce9ab521155c25d2e0b320de0879a927def69cf41b30
data/README.md CHANGED
@@ -5,75 +5,73 @@
5
5
  Counting and aggregation library for Rails.
6
6
 
7
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
- - [Testing the counters in production](#testing-the-counters-in-production)
19
- - [TODO](#todo)
20
8
  - [Usage](#usage)
21
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)
22
28
  - [Contributing](#contributing)
23
29
  - [License](#license)
24
30
 
25
- 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.
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.
26
32
 
27
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):
28
34
 
29
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
30
36
  - Counters are persisted as a ActiveRecord models (_not_ a column of an existing model)
31
- - Incrementing counters can be safely performed in a background job via a change event/deferred reconciliation pattern
37
+ - Counters can also perform aggregation (e.g. sum of column values instead of counting rows) or be calculated from other counters
32
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.
33
- - Counters can also perform aggregation (e.g. sum of column values instead of counting rows)
39
+ - Incrementing counters can be safely performed in a background job via a change event/deferred reconciliation pattern (coming in a future iteration)
34
40
 
41
+ ## Usage
35
42
 
36
- ## Main concepts
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.
37
44
 
38
- ![](docs/data_model.png)
45
+ ## Installation
39
46
 
40
- `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…
47
+ Add this line to your application's Gemfile:
41
48
 
42
- `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`
49
+ ```ruby
50
+ gem 'counterwise', require: 'counter'
51
+ ```
43
52
 
44
- `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.
53
+ And then execute:
45
54
 
46
- 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.
55
+ ```bash
56
+ $ bundle
57
+ ```
47
58
 
48
- Basically updating a counter value requires this SQL:
59
+ Install the model migrations:
49
60
 
50
- ```sql
51
- UPDATE counter_values
52
- -- Update the counter with the sum of pending changes
53
- SET value = value + changes.sum
54
- FROM (
55
- -- Find the pending changes for the counter
56
- SELECT sum(value) as sum
57
- FROM counter_changes
58
- WHERE counter_id = 100
59
- ) as changes
60
- WHERE id = 100
61
+ ```bash
62
+ $ rails counter:install:migrations
61
63
  ```
62
64
 
63
- Or even reconcile all pending counters in a single statement:
64
-
65
- ```sql
66
- UPDATE counter_values
67
- SET value = value + changes.sum
68
- FROM (
69
- SELECT sum(value)
70
- FROM counter_changes
71
- GROUP BY counter_id
72
- ) as changes
73
- WHERE counters.id = counter_id
74
- ```
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`
75
72
 
76
- ## Defining a counter
73
+ ## Basic usage
74
+ ### Define a counter
77
75
 
78
76
  Counters are defined in a seperate class using a small DSL.
79
77
 
@@ -94,20 +92,21 @@ end
94
92
 
95
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.
96
94
 
97
- By default, the counter will be available as `<association>_counter`, e.g. `store.orders_counter`. To customise this, pass a `as` parameter:
95
+ By default, the counter will be available as `<association>_counter`, e.g. `store.orders_counter`. To customise this, use the `as` method:
98
96
 
99
97
  ```ruby
100
98
  class OrderCounter < Counter::Definition
101
99
  include Counter::Counters
102
- count :orders, as: :total_orders
100
+ count :orders
101
+ as :total_orders
103
102
  end
104
103
 
105
104
  store.total_orders
106
105
  ```
107
106
 
108
- The counter's value with be stored as a `Counter::Value` with the name prefixed by the model name. e.g. `store_total_orders`
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`
109
108
 
110
- ## Accessing counter values
109
+ ### Access counter values
111
110
 
112
111
  Since counters are represented as objects, you need to call `value` on them to retrieve the count.
113
112
 
@@ -116,78 +115,76 @@ store.total_orders #=> Counter::Value
116
115
  store.total_orders.value #=> 200
117
116
  ```
118
117
 
119
- ## Anonymous counters
118
+ ### Recalculate a counter
120
119
 
121
- 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.
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.
122
121
 
123
- ```ruby
124
- class GlobalOrderCounter < Counter::Definition
125
- global :my_custom_counter_name
126
- end
122
+ You could refresh a store's revenue stats with:
127
123
 
128
- GlobalOrderCounter.counter.value #=> 5
129
- GlobalOrderCounter.counter.increment! #=> 6
124
+ ```ruby
125
+ store.order_revenue.recalc!
130
126
  ```
131
127
 
132
- ## Defining a conditional counter
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
133
131
 
134
- 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
132
+ You can also reset a counter by calling `reset`. Since counters are ActiveRecord objects, you could also reset them using
135
133
 
136
134
  ```ruby
137
- class Product < ApplicationRecord
138
- include Counter::Counters
139
- include Counter::Changable
135
+ store.order_revenue.reset
136
+ Counter::Value.update value: 0
137
+ ```
140
138
 
141
- belongs_to :user
139
+ ### Verify a counter
142
140
 
143
- scope :premium, -> { where("price >= 1000") }
141
+ You might like to check if a counter is correct
144
142
 
145
- def premium?
146
- price >= 1000
147
- end
148
- end
143
+ ```ruby
144
+ store.product_revenue.correct? #=> false
149
145
  ```
150
146
 
151
- Here's the counter to do that:
147
+ 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!`:
152
148
 
153
149
  ```ruby
154
- class PremiumProductCounter < Counter::Definition
155
- # Define the association we're counting
156
- count :premium_products
150
+ store.product_revenue #=>200
151
+ store.product_revenue.reset!
152
+ store.product_revenue #=>0
153
+ store.product_revenue.correct? #=> false
154
+ store.product_revenue.correct! #=> false
155
+ store.product_revenue #=>200
156
+ ```
157
157
 
158
- on :create do
159
- increment_if ->(product) { product.premium? }
160
- end
158
+ ## Advanced usage
161
159
 
162
- on :delete do
163
- decrement_if ->(product) { product.premium? }
164
- end
160
+ ### Sort or filter parent models by a counter value
165
161
 
166
- on :update do
167
- increment_if ->(product) {
168
- product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 }
169
- }
162
+ Say a Customer has a `total revenue` counter, and you'd like to sort the list of customers with the highest spenders at the top. Since the counts aren't stored on the Customer model, you can't just call `Customer.order(total_orders: :desc)`. Instead, Counterwise provides a convenience method to pull the counter values into the resultset.
170
163
 
171
- decrement_if ->(product) {
172
- product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 }
173
- }
174
- end
175
- end
176
- ```
164
+ ```ruby
165
+ Customer.order_by_counter TotalRevenueCounter => :desc
177
166
 
178
- There is a lot going on here!
167
+ # You can sort by multiple counters or mix counters and model attributes
168
+ Customer.order_by_counter TotalRevenueCounter => :desc, name: :asc
169
+ ```
179
170
 
180
- 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.
171
+ 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.
181
172
 
182
- 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.
173
+ ```ruby
174
+ Customer.with_counter_data_from(TotalRevenueCounter).where("total_revenue_data > 1000")
175
+ ```
183
176
 
184
- `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.
177
+ These methods pull in the counter data itself but don't include the counter instances themselves. To do this, call
185
178
 
186
- 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)
179
+ ```ruby
180
+ customers = Customer.with_counters TotalRevenueCounter
181
+ # Since the counters are now preloaded, this avoids an N+1 query
182
+ customers.each &:total_revenue
183
+ ```
187
184
 
188
- 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).
185
+ ### Aggregate a value (e.g. sum of order revenue)
189
186
 
190
- ## Aggregating a value (e.g. sum of order revenue)
187
+ Sometimes you don'y want to count the number of orders but instead sum the value of those orders..
191
188
 
192
189
  Given an ActiveRecord model `Order`, we can count a storefront's revenue like so
193
190
 
@@ -216,122 +213,209 @@ and access it like
216
213
  store.order_revenue.value #=> 200
217
214
  ```
218
215
 
219
- ## Recalculating a counter
220
-
221
- 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.
216
+ ### Hooks
222
217
 
223
- You could refresh a store's revenue stats with:
218
+ 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
219
 
225
220
  ```ruby
226
- store.order_revenue.recalc!
221
+ class OrderRevenueCounter < Counter::Definition
222
+ count :orders, as: :order_revenue
223
+ sum :price
224
+
225
+ after_change :send_congratulations_email
226
+
227
+ # Only send an email when they cross $1000
228
+ def send_congratulations_email counter, old_value, new_value
229
+ return unless old_value < 1000 && new_value >= 1000
230
+ send_email "Congratulations! You've made #{to} dollars!"
231
+ end
232
+ end
227
233
  ```
228
234
 
229
- 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.
235
+ ### Manual counters
230
236
 
231
- ## Reset a counter
237
+ 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.
232
238
 
233
- You can also reset a counter by calling `reset`. Since counters are ActiveRecord objects, you could also reset them using
239
+ Manual counters just need a name
234
240
 
235
241
  ```ruby
236
- store.order_revenue.reset
237
- Counter::Value.update value: 0
242
+ class TotalOrderCounter < Counter::Definition
243
+ as "total_orders"
244
+ end
245
+
246
+ TotalOrderCounter.counter.value #=> 5
247
+ TotalOrderCounter.counter.increment! #=> 6
238
248
  ```
239
249
 
240
- ## Verify a counter
250
+ ### Calculating a value from other counters
241
251
 
242
- You might like to check if a counter is correct
252
+ 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.
243
253
 
244
254
  ```ruby
245
- store.product_revenue.correct? #=> false
255
+ class ConversionRateCounter < Counter::Definition
256
+ count nil, as: "conversion_rate"
257
+
258
+ calculated_from VisitsCounter, OrdersCounter do |visits, orders|
259
+ (orders.value.to_f / visits.value) * 100
260
+ end
261
+ end
246
262
  ```
247
263
 
248
- 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!`:
264
+ 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).
265
+
266
+ ### Defining a conditional counter
267
+
268
+ Conditional counters allow you to count a subset of an association, like just the premium product with a price >= 1000.
249
269
 
250
270
  ```ruby
251
- store.product_revenue #=>200
252
- store.product_revenue.reset!
253
- store.product_revenue #=>0
254
- store.product_revenue.correct? #=> false
255
- store.product_revenue.correct! #=> false
256
- store.product_revenue #=>200
257
- ```
271
+ class Product < ApplicationRecord
272
+ include Counter::Counters
273
+ include Counter::Changable
258
274
 
259
- ## Hooks
275
+ belongs_to :user
260
276
 
261
- 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.
277
+ scope :premium, -> { where("price >= 1000") }
278
+
279
+ def premium?
280
+ price >= 1000
281
+ end
282
+ end
283
+ ```
284
+
285
+ 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.
262
286
 
263
287
  ```ruby
264
- class OrderRevenueCounter < Counter::Definition
265
- count :orders, as: :order_revenue
266
- sum :price
288
+ class PremiumProductCounter < Counter::Definition
289
+ # Define the association we're counting
290
+ count :premium_products
267
291
 
268
- after_change :send_congratulations_email
292
+ on :create do
293
+ increment_if ->(product) { product.premium? }
294
+ end
269
295
 
270
- def send_congratulations_email counter, from, to
271
- return unless from < 1000 && to >= 1000
272
- send_email "Congratulations! You've made #{to} dollars!"
296
+ on :delete do
297
+ decrement_if ->(product) { product.premium? }
298
+ end
299
+
300
+ on :update do
301
+ increment_if ->(product) {
302
+ product.has_changed? :price, from: ->(price) { price < 1000 }, to: ->(price) { price >= 1000 }
303
+ }
304
+
305
+ decrement_if ->(product) {
306
+ product.has_changed? :price, from: ->(price) { price >= 1000 }, to: ->(price) { price < 1000 }
307
+ }
273
308
  end
274
309
  end
275
310
  ```
276
311
 
277
- ## Testing the counters in production
312
+ There is a lot going on here!
313
+
314
+ 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.
315
+
316
+ 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.
317
+
318
+ `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.
319
+
320
+ 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)
321
+
322
+ 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).
323
+
278
324
 
279
- It may be useful to verify the accuracy of the counters in production, especially if you are concerned about conditional counters etc causing counter drift over time.
325
+ ## Testing
280
326
 
281
- This form of script takes a sampling approach suitable for large collections. It will randomly select a record and verify that the counter value is correct; if it's not, it stops giving you a chance to investigate.
327
+ ### Using Rspec
328
+
329
+ If you use RSpec, you can include `Counter::RSpecMatchers` on your helpers and test your counter definitions.
282
330
 
283
331
  ```ruby
284
- site_range = Site.minimum(:id)..Site.maximum(:id)
285
-
286
- 1000.times do
287
- random_id = rand(site_range)
288
- site = Site.where("id >= ?", random_id).limit(1).first
289
- next if site.nil?
290
- if site.confirmed_subscribers_counter.correct?
291
- puts "✅ site #{site.id} has correct counter value"
292
- else
293
- puts "❌ site #{site.id} has incorrect counter value. Expected #{site.confirmed_subscribers_counter.value} but got #{site.confirmed_subscribers_counter.count_by_sql}"
294
- break
295
- end
296
- sleep 0.1
332
+ require "counter/rspec/matchers"
333
+
334
+ RSpec.configure do |config|
335
+ config.include Counter::RSpecMatchers, type: :counter
297
336
  end
298
337
  ```
299
338
 
300
- ---
339
+ Now you can test your counter definitions like so:
301
340
 
302
- ## TODO
341
+ ```ruby
342
+ require "rails_helper"
343
+
344
+ RSpec.describe PremiumProductCounter, type: :counter do
345
+ let(:store) { create(:store) }
346
+
347
+ describe "on :create" do
348
+ context "when the product is premium" do
349
+ it "increments the counter" do
350
+ expect { create(:product, :premium, store: store) }.to increment_counter_for(described_class, store)
351
+ end
352
+ end
353
+
354
+ context "when the product is not premium" do
355
+ it "doesn't increment the counter" do
356
+ expect { create(:product, store: store) }.not_to increment_counter_for(described_class, store)
357
+ end
358
+ end
359
+ end
303
360
 
304
- See the asociated project in Github but roughly I'm thinking:
305
- - 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?
306
- - 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?
307
- - 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?
308
- - 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.
309
- - Actually start running this in production for basic use cases
361
+ describe "on :delete" do
362
+ context "when the product is premium" do
363
+ it "decrements the counter" do
364
+ expect { create(:product, :premium, store: store) }.to decrement_counter_for(described_class, store)
365
+ end
366
+ end
367
+
368
+ context "when the product is not premium" do
369
+ it "doesn't decrement the counter" do
370
+ expect { create(:product, store: store) }.not_to decrement_counter_for(described_class, store)
371
+ end
372
+ end
373
+ end
374
+ end
375
+ ```
310
376
 
311
- ## Usage
312
- No one has used this in production yet.
377
+ ### In production
313
378
 
314
- You probably shouldn't right now unless you're the sort of person that checks if something is poisonous by licking it.
379
+ > test in prod or live a lie Charity Majors
315
380
 
316
- ## Installation
317
- Add this line to your application's Gemfile:
381
+ 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.
382
+
383
+ A simple approach would be:
318
384
 
319
385
  ```ruby
320
- gem 'counter'
386
+ Counter::Value.all.each &:correct!
321
387
  ```
322
388
 
323
- And then execute:
324
- ```bash
325
- $ bundle
326
- ```
389
+ 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
327
390
 
328
- Install the model migrations:
329
- ```bash
330
- $ rails counter:install:migrations
391
+ ```ruby
392
+ Counter::Value.sample_and_verify samples: 1000, verbose: true, on_error: :correct
331
393
  ```
332
394
 
395
+ Options:
396
+
397
+ - 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
398
+ - samples — the number of counters to sample. Default: 1000
399
+ - verbose — print out the counter details and whether it was correct. Default: true
400
+ - 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
401
+
402
+ ---
403
+
404
+ ## TODO
405
+
406
+ See the asociated project in Github but roughly I'm thinking:
407
+ - Implement the background job pattern for incrementing counters
408
+ - 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?
409
+ - 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?
410
+ - 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.
411
+ - Actually start running this in production for basic use cases
412
+
333
413
  ## Contributing
334
- Contribution directions go here.
414
+
415
+ 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.
416
+
417
+ I'm unlikely to entertain suport for older Ruby or Rails versions, or databases other than Postgres.
335
418
 
336
419
  ## License
420
+
337
421
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,25 @@
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
@@ -1,11 +1,16 @@
1
1
  module Counter::Recalculatable
2
2
  extend ActiveSupport::Concern
3
3
 
4
- ####################################################### Support for regenerating the counters
5
4
  def recalc!
6
- with_lock do
7
- new_value = definition.sum? ? sum_by_sql : count_by_sql
8
- update! value: new_value
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
9
14
  end
10
15
  end
11
16
 
@@ -2,13 +2,66 @@ module Counter::Verifyable
2
2
  extend ActiveSupport::Concern
3
3
 
4
4
  def correct?
5
- count_by_sql == value
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
6
10
  end
7
11
 
8
12
  def correct!
9
- requires_recalculation = !correct?
10
- recalc! if requires_recalculation
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
11
20
 
12
21
  !requires_recalculation
13
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
+ counter_range = Counter::Value.minimum(:id)..Counter::Value.maximum(:id)
39
+
40
+ samples.times do
41
+ random_id = rand(counter_range)
42
+ counter = Counter::Value.merge(scope).where("id >= ?", random_id).limit(1).first
43
+ next if counter.nil?
44
+
45
+ if counter.definition.global? || counter.definition.calculated?
46
+ puts "➡️ Skipping counter #{counter.name} (#{counter.id})" if verbose
47
+ next
48
+ end
49
+
50
+ if counter.correct?
51
+ puts "✅ Counter #{counter.id} is correct" if verbose
52
+ else
53
+ incorrect_counters += 1
54
+ 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}"
55
+
56
+ case on_error
57
+ when :raise then raise Counter::Error.new(message)
58
+ when :log then Rails.logger.error message
59
+ when :correct then counter.correct!
60
+ end
61
+ end
62
+ sleep 0.1
63
+ end
64
+ incorrect_counters
65
+ end
66
+ end
14
67
  end
@@ -43,5 +43,6 @@ class Counter::Value < ApplicationRecord
43
43
  include Counter::Verifyable
44
44
  include Counter::Summable
45
45
  include Counter::Conditional
46
+ include Counter::Calculated
46
47
  # include Counter::Hierarchical
47
48
  end
@@ -2,7 +2,7 @@ class CreateCounterValues < ActiveRecord::Migration[6.1]
2
2
  def change
3
3
  create_table :counter_values do |t|
4
4
  t.string :name, index: true
5
- t.integer :value, default: 0
5
+ t.decimal :value, default: 0.0, null: false
6
6
  t.references :parent, polymorphic: true
7
7
 
8
8
  t.timestamps
@@ -5,11 +5,7 @@
5
5
  # # This specifies the association we're counting
6
6
  # count :products
7
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
- # }
8
+ # as "my_counter"
13
9
  # end
14
10
  class Counter::Definition
15
11
  include Singleton
@@ -34,21 +30,38 @@ class Counter::Definition
34
30
  attr_writer :global_counters
35
31
  # An array of Proc to run when the counter changes
36
32
  attr_writer :counter_hooks
37
- # An array of all global counters
38
- attr_writer :global_counters
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
39
37
 
38
+ # Is this a counter which sums a column?
40
39
  def sum?
41
40
  column_to_count.present?
42
41
  end
43
42
 
43
+ # Is this a global counter? i.e., not attached to a model
44
44
  def global?
45
- model.nil? && association_name.nil?
45
+ model.nil?
46
46
  end
47
47
 
48
+ # Is this counter conditional?
48
49
  def conditional?
49
50
  @conditional
50
51
  end
51
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
+
52
65
  # for global counter instances to find their definition
53
66
  def self.find_definition name
54
67
  Counter::Definition.instance.global_counters.find { |c| c.name == name }
@@ -64,7 +77,8 @@ class Counter::Definition
64
77
  # What we record in Counter::Value#name
65
78
  def record_name
66
79
  return name if global?
67
- "#{model.name.underscore}-#{association_name}"
80
+ return "#{model.name.underscore}-#{association_name}" if association_name.present?
81
+ return "#{model.name.underscore}-#{name}"
68
82
  end
69
83
 
70
84
  def conditions
@@ -82,9 +96,9 @@ class Counter::Definition
82
96
  @counter_hooks
83
97
  end
84
98
 
85
- def global_counters
86
- @global_counters ||= []
87
- @global_counters
99
+ def dependent_counters
100
+ @dependent_counters ||= []
101
+ @dependent_counters
88
102
  end
89
103
 
90
104
  # Set the association we're counting
@@ -95,12 +109,36 @@ class Counter::Definition
95
109
  instance.method_name = as.to_s
96
110
  end
97
111
 
98
- def self.global name = nil
99
- name ||= name.underscore
100
- instance.name = name.to_s
112
+ def self.global
101
113
  Counter::Definition.instance.global_counters << instance
102
114
  end
103
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
+
104
142
  # Get the name of the association we're counting
105
143
  def self.association_name
106
144
  instance.association_name
@@ -26,6 +26,8 @@ module Counter::Countable
26
26
  def each_counter_to_update
27
27
  # For each definition, find or create the counter on the parent
28
28
  self.class.counted_by.each do |counter_definition|
29
+ next unless counter_definition.inverse_association
30
+
29
31
  parent_association = association(counter_definition.inverse_association)
30
32
  parent_association.load_target unless parent_association.loaded?
31
33
  parent_model = parent_association.target
@@ -55,34 +55,66 @@ module Counter::Counters
55
55
  counter_definitions = Array.wrap(counter_definitions)
56
56
  counter_definitions.each do |definition_class|
57
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
58
  definition.model = self
75
- definition.inverse_association = inverse_association.name
76
- definition.countable_model = association_class
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) }
77
89
 
78
90
  define_method definition.method_name do
79
91
  counters.find_or_create_counter!(definition)
80
92
  end
81
93
 
82
- # Provide the Countable class with details about where it's counted
94
+ @counter_configs << definition unless @counter_configs.include?(definition)
83
95
 
84
- @counter_configs << definition
85
- association_class.add_counted_by definition
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
86
118
  end
87
119
  end
88
120
 
@@ -0,0 +1,29 @@
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
@@ -1,3 +1,3 @@
1
1
  module Counter
2
- VERSION = "0.1.1"
2
+ VERSION = "0.1.2"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: counterwise
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamie Lawrence
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-03 00:00:00.000000000 Z
11
+ date: 2023-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -52,6 +52,7 @@ files:
52
52
  - app/controllers/counters_controller.rb
53
53
  - app/jobs/counter/reconciliation_job.rb
54
54
  - app/models/concerns/counter/Xhierarchical.rb
55
+ - app/models/concerns/counter/calculated.rb
55
56
  - app/models/concerns/counter/changable.rb
56
57
  - app/models/concerns/counter/conditional.rb
57
58
  - app/models/concerns/counter/definable.rb
@@ -62,11 +63,9 @@ files:
62
63
  - app/models/concerns/counter/sidekiq_reconciliation.rb
63
64
  - app/models/concerns/counter/summable.rb
64
65
  - app/models/concerns/counter/verifyable.rb
65
- - app/models/counter/change.rb
66
66
  - app/models/counter/value.rb
67
67
  - config/routes.rb
68
68
  - db/migrate/20210705154113_create_counter_values.rb
69
- - db/migrate/20210709211056_create_counter_changes.rb
70
69
  - db/migrate/20210731224504_add_unique_index_to_counter_values.rb
71
70
  - lib/counter.rb
72
71
  - lib/counter/any.rb
@@ -77,6 +76,7 @@ files:
77
76
  - lib/counter/integration/countable.rb
78
77
  - lib/counter/integration/counters.rb
79
78
  - lib/counter/railtie.rb
79
+ - lib/counter/rspec/matchers.rb
80
80
  - lib/counter/version.rb
81
81
  - lib/tasks/counter_tasks.rake
82
82
  homepage: https://github.com/podia/counter
@@ -1,23 +0,0 @@
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
@@ -1,11 +0,0 @@
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