counterwise 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +313 -0
- data/Rakefile +18 -0
- data/app/assets/config/counter_manifest.js +0 -0
- data/app/controllers/counters_controller.rb +17 -0
- data/app/jobs/counter/reconciliation_job.rb +12 -0
- data/app/models/concerns/counter/Xhierarchical.rb +23 -0
- data/app/models/concerns/counter/changable.rb +42 -0
- data/app/models/concerns/counter/conditional.rb +32 -0
- data/app/models/concerns/counter/definable.rb +17 -0
- data/app/models/concerns/counter/hooks.rb +17 -0
- data/app/models/concerns/counter/increment.rb +48 -0
- data/app/models/concerns/counter/recalculatable.rb +24 -0
- data/app/models/concerns/counter/reset.rb +11 -0
- data/app/models/concerns/counter/sidekiq_reconciliation.rb +60 -0
- data/app/models/concerns/counter/summable.rb +15 -0
- data/app/models/concerns/counter/verifyable.rb +14 -0
- data/app/models/counter/change.rb +23 -0
- data/app/models/counter/value.rb +47 -0
- data/config/routes.rb +3 -0
- data/db/migrate/20210705154113_create_counter_values.rb +11 -0
- data/db/migrate/20210709211056_create_counter_changes.rb +11 -0
- data/db/migrate/20210731224504_add_unique_index_to_counter_values.rb +6 -0
- data/lib/counter/any.rb +6 -0
- data/lib/counter/conditions.rb +16 -0
- data/lib/counter/definition.rb +128 -0
- data/lib/counter/engine.rb +5 -0
- data/lib/counter/error.rb +2 -0
- data/lib/counter/integration/countable.rb +48 -0
- data/lib/counter/integration/counters.rb +94 -0
- data/lib/counter/railtie.rb +4 -0
- data/lib/counter/version.rb +3 -0
- data/lib/counter.rb +12 -0
- data/lib/tasks/counter_tasks.rake +4 -0
- 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
|
+
[](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
|
+

|
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,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,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
data/lib/counter/any.rb
ADDED
@@ -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,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
|
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
|
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: []
|