counterwise 0.1.5 → 0.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3058578dbb5742b22c61ec6bfbd79253bd39886684760bd1afe2674c4e97eb00
4
- data.tar.gz: 8cd7bb0ad3fd770090cd3a156dca65a7d5c6dd9ba607dbcce500c6c94f182a31
3
+ metadata.gz: 872eddb45910800263c3c79d08192c149f1a93e406b777ace0e37a069b27c581
4
+ data.tar.gz: f1c75031995349a525f45ef0127dbad1dd8e922a31c638936ebecd5da8995b7e
5
5
  SHA512:
6
- metadata.gz: 2ebfb183bba510a0e2322e73d7881fed711369575ceb1c48fa128ccacb1482fdec5dbc7b631232d3de0c9f84c4ea4c9620552b96b82212a1751be209ea6d29ec
7
- data.tar.gz: 5ed67f84bfffa86e5d438477a4c8f184cfa7b6e0bd26040e56defca7b3ba5877527c57a78b3d9f5868c75860d7b305225c82be24665761b9d3d7952a6bd3809f
6
+ metadata.gz: acc758026ab1392efd1a18abed79901b51a9b19b3b310e6dcbb71fdf4daec4b06e3c5a191f7232f47a25b1fc402ad054c769daa32d4ea080632cbad5383df939
7
+ data.tar.gz: a81196e23f65e894f2c31780633255fdf2900fe109ea5a7b240d9cd2d54310c79d8a6511886af1208e441b80214e42dcc455b06c1b3a0b7c8cbbd6af2b5de1f9
data/README.md CHANGED
@@ -19,6 +19,7 @@ Counting and aggregation library for Rails.
19
19
  - [Aggregate a value (e.g. sum of order revenue)](#aggregate-a-value-eg-sum-of-order-revenue)
20
20
  - [Hooks](#hooks)
21
21
  - [Manual counters](#manual-counters)
22
+ - [Manually calculating a value](#manually-calculating-a-value)
22
23
  - [Calculating a value from other counters](#calculating-a-value-from-other-counters)
23
24
  - [Defining a conditional counter](#defining-a-conditional-counter)
24
25
  - [Testing](#testing)
@@ -71,6 +72,7 @@ $ rails counter:install:migrations
71
72
  `Counter::Value` is the value of a counter. So, for example, a User might have many Posts, so a User would have a `counters` association containing a `Counter::Value` for the number of posts. Counters can be accessed via their name `user.posts_counter` or via the `find_counter` method on the association, e.g. `user.counters.find_counter PostCounter`
72
73
 
73
74
  ## Basic usage
75
+
74
76
  ### Define a counter
75
77
 
76
78
  Counters are defined in a seperate class using a small DSL.
@@ -252,6 +254,34 @@ TotalOrderCounter.counter.value #=> 5
252
254
  TotalOrderCounter.counter.increment! #=> 6
253
255
  ```
254
256
 
257
+ ### Manually calculating a value
258
+
259
+ There are edge cases where a counter can't be calculated by associations or other counters. You can tell the counter its value manually, in these cases, by using `calculated_value`. Calculated value takes a lambda and an association. The lambda is called each time an associated record is created, updated, or destroyed.
260
+
261
+ ```ruby
262
+ class RiskyOrdersCounter < Counter::Definition
263
+ calculated_value ->(customer) { customer.orders.risky.count }, association: :orders
264
+ end
265
+
266
+ class Customer
267
+ include Counters::Counter
268
+ counter RiskyOrdersCounter
269
+ end
270
+
271
+ class Order
272
+ include Counter::Changable
273
+ end
274
+ ```
275
+
276
+ You may want to change the auto-generated name of the counter value. In this case, you can provide that with `record_name`.
277
+
278
+ ```ruby
279
+ class RiskyOrdersCounter < Counter::Definition
280
+ calculated_value ->(customer) { customer.orders.risky.count }, association: :orders
281
+ record_name :risky_customer_orders
282
+ end
283
+ ```
284
+
255
285
  ### Calculating a value from other counters
256
286
 
257
287
  You may also need have a common need to calculate a value from other counters. For example, given counters for the number of purchases and the number of visits, you might want to calculate the conversion rate. You can do this with a `calculate_from` block.
@@ -326,7 +356,6 @@ We use the `has_changed?` helper to query the ActiveRecord `previous_changes` ha
326
356
 
327
357
  Conditional counters work best with a single attribute. If the counter is conditional on e.g. confirmed and subscribed, the update tracking logic becomes very complex especially if the values are both updated at the same time. The solution to this is hopefully Rails generated columns in 7.1 so you can store a "subscribed_and_confirmed" column and check the value of that instead. Rails dirty tracking will need to work with generated columns though; see [this PR](https://github.com/rails/rails/pull/48628).
328
358
 
329
-
330
359
  ## Testing
331
360
 
332
361
  ### Using Rspec
@@ -406,9 +435,42 @@ Options:
406
435
 
407
436
  ---
408
437
 
438
+ ## Release Instructions
439
+
440
+ To release a new version of the counterwise gem:
441
+
442
+ 1. **Merge changes**: Ensure all changes are merged into the main branch via pull requests.
443
+
444
+ 2. **Update version**: Bump the version number in `lib/counter/version.rb`:
445
+
446
+ ```ruby
447
+ module Counter
448
+ VERSION = "x.y.z" # Update to new version
449
+ end
450
+ ```
451
+
452
+ 3. **Commit and push version bump**:
453
+
454
+ ```bash
455
+ git add lib/counter/version.rb
456
+ git commit -m "Bump version to x.y.z"
457
+ git push origin main
458
+ ```
459
+
460
+ 4. **Build and release**:
461
+ ```bash
462
+ gem build counter.gemspec
463
+ gem push counterwise-x.y.z.gem
464
+ ```
465
+
466
+ The gem will be available on [RubyGems.org](https://rubygems.org/gems/counterwise) within a few minutes.
467
+
468
+ ---
469
+
409
470
  ## TODO
410
471
 
411
472
  See the asociated project in Github but roughly I'm thinking:
473
+
412
474
  - Implement the background job pattern for incrementing counters
413
475
  - Hierarchical counters. For example, a Site sends many Newsletters and each Newsletter results in many EmailMessages. Each EmailMessage can be marked as spam. How do you create counters for how many spam emails were sent at the Newsletter level and the Site level?
414
476
  - Time-based counters for analytics. Instead of a User having one OrderRevenue counter, they would have an OrderRevenue counter for each day. These counters would then be used to produce a chart of their product revenue over the month. Not sure if these are just special counters or something else entirely? Do they use the same ActiveRecord model?
@@ -11,6 +11,7 @@ module Counter::Conditional
11
11
  end
12
12
 
13
13
  def accept_item? item, on, increment: true
14
+ return false if definition.calculated_value?
14
15
  return true unless definition.conditional?
15
16
 
16
17
  conditions = definition.conditions[on]
@@ -19,18 +19,33 @@ module Counter::Increment
19
19
  end
20
20
 
21
21
  def add_item item
22
+ if definition.calculated_value?
23
+ recalc!
24
+ return
25
+ end
26
+
22
27
  return unless increment?(item, :create)
23
28
 
24
29
  increment! by: increment_from_item(item)
25
30
  end
26
31
 
27
32
  def remove_item item
33
+ if definition.calculated_value?
34
+ recalc!
35
+ return
36
+ end
37
+
28
38
  return unless decrement?(item, :delete)
29
39
 
30
40
  decrement! by: increment_from_item(item)
31
41
  end
32
42
 
33
43
  def update_item item
44
+ if definition.calculated_value?
45
+ recalc!
46
+ return
47
+ end
48
+
34
49
  if increment?(item, :update)
35
50
  increment! by: increment_from_item(item)
36
51
  end
@@ -2,7 +2,9 @@ module Counter::Recalculatable
2
2
  extend ActiveSupport::Concern
3
3
 
4
4
  def recalc!
5
- if definition.calculated?
5
+ if definition.calculated_value?
6
+ recalculate_with_value!
7
+ elsif definition.calculated?
6
8
  calculate!
7
9
  elsif definition.manual?
8
10
  raise Counter::Error.new("Can't recalculate a manual counter")
@@ -26,4 +28,12 @@ module Counter::Recalculatable
26
28
  def recalc_scope
27
29
  parent.association(definition.association_name).scope
28
30
  end
31
+
32
+ private
33
+
34
+ def recalculate_with_value!
35
+ with_lock do
36
+ update!(value: definition.calculated_value.call(parent))
37
+ end
38
+ end
29
39
  end
@@ -34,6 +34,10 @@ class Counter::Definition
34
34
  attr_writer :dependent_counters
35
35
  # The block to call to calculate the counter
36
36
  attr_accessor :calculated_from
37
+ # The block used to manually set the value
38
+ attr_accessor :calculated_value
39
+ # The counter's record name
40
+ attr_writer :record_name
37
41
 
38
42
  # Is this a counter which sums a column?
39
43
  def sum?
@@ -50,6 +54,11 @@ class Counter::Definition
50
54
  @conditional
51
55
  end
52
56
 
57
+ # Is this counter using a calculated value?
58
+ def calculated_value?
59
+ @calculated_value.present?
60
+ end
61
+
53
62
  # Is this counter calculated from other counters?
54
63
  def calculated?
55
64
  !@calculated_from.nil?
@@ -74,11 +83,16 @@ class Counter::Definition
74
83
  Counter::Value.find_counter self
75
84
  end
76
85
 
86
+ def self.record_name(value)
87
+ instance.record_name = value.to_s
88
+ end
89
+
77
90
  # What we record in Counter::Value#name
78
91
  def record_name
92
+ return @record_name if @record_name.present?
79
93
  return name if global?
80
94
  return "#{model.name.underscore}-#{association_name}" if association_name.present?
81
- return "#{model.name.underscore}-#{name}"
95
+ "#{model.name.underscore}-#{name}"
82
96
  end
83
97
 
84
98
  def conditions
@@ -109,6 +123,17 @@ class Counter::Definition
109
123
  instance.method_name = as.to_s
110
124
  end
111
125
 
126
+ def self.calculated_value(calculation, association: nil)
127
+ instance.association_name = association
128
+ instance.calculated_value = calculation
129
+ set_default_name
130
+ end
131
+
132
+ def self.set_default_name
133
+ instance.name ||= to_s.underscore
134
+ instance.method_name ||= to_s.underscore
135
+ end
136
+
112
137
  def self.global
113
138
  Counter::Definition.instance.global_counters << instance
114
139
  end
@@ -32,8 +32,11 @@ module Counter::Countable
32
32
  parent_association.load_target unless parent_association.loaded?
33
33
  parent_model = parent_association.target
34
34
  next unless parent_model
35
- counter = parent_model.counters.find_or_create_counter!(counter_definition)
36
- yield counter if counter
35
+
36
+ if parent_model.class.reflect_on_association(:counters) && parent_model.is_a?(counter_definition.model)
37
+ counter = parent_model.counters.find_or_create_counter!(counter_definition)
38
+ yield counter if counter
39
+ end
37
40
  end
38
41
  end
39
42
  end
@@ -57,13 +57,24 @@ module Counter::Counters
57
57
  definition = definition_class.instance
58
58
  definition.model = self
59
59
 
60
+ counter_subquery = ->(counter_class) do
61
+ record_name = counter_class.instance.record_name
62
+
63
+ Counter::Value
64
+ .select(:value)
65
+ .where("parent_id = #{table_name}.id AND parent_type = '#{name}' AND name = '#{record_name}'")
66
+ .limit(1)
67
+ .to_sql
68
+ end
69
+
60
70
  scope :with_counter_data_from, ->(*counter_classes) {
61
71
  subqueries = ["#{table_name}.*"]
72
+
62
73
  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"
74
+ subquery = counter_subquery.call(counter_class)
75
+ subqueries << Arel.sql("(#{subquery}) AS #{"#{counter_class.instance.name}_data"}")
66
76
  end
77
+
67
78
  select(subqueries)
68
79
  }
69
80
 
@@ -74,15 +85,16 @@ module Counter::Counters
74
85
  counter_class.is_a?(Class) &&
75
86
  counter_class.ancestors.include?(Counter::Definition)
76
87
  }
77
- order_params = {}
78
- order_hash.map do |counter_class, direction|
88
+
89
+ order_clauses = order_hash.map do |counter_class, direction|
79
90
  if counter_class.is_a?(String) || counter_class.is_a?(Symbol)
80
- order_params[counter_class] = direction
91
+ "#{counter_class} #{direction.to_s.upcase}"
81
92
  elsif counter_class.ancestors.include?(Counter::Definition)
82
- order_params["#{counter_class.instance.name}_data"] = direction
93
+ "(#{counter_subquery.call(counter_class)}) #{direction.to_s.upcase}"
83
94
  end
84
95
  end
85
- with_counter_data_from(*counter_classes).order(order_params)
96
+
97
+ with_counter_data_from(*counter_classes).order(Arel.sql(order_clauses.join(", ")))
86
98
  }
87
99
 
88
100
  scope :with_counters, -> { includes(:counters) }
@@ -1,3 +1,3 @@
1
1
  module Counter
2
- VERSION = "0.1.5"
2
+ VERSION = "0.1.7"
3
3
  end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: counterwise
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamie Lawrence
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2024-04-15 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -16,14 +15,14 @@ dependencies:
16
15
  requirements:
17
16
  - - ">="
18
17
  - !ruby/object:Gem::Version
19
- version: '7'
18
+ version: '8'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - ">="
25
24
  - !ruby/object:Gem::Version
26
- version: '7'
25
+ version: '8'
27
26
  description: Counting and aggregation library for Rails.
28
27
  email:
29
28
  - jamie@ideasasylum.com
@@ -72,7 +71,6 @@ metadata:
72
71
  homepage_uri: https://github.com/podia/counter
73
72
  source_code_uri: https://github.com/podia/counter
74
73
  changelog_uri: https://github.com/podia/counter/CHANGELOG.md
75
- post_install_message:
76
74
  rdoc_options: []
77
75
  require_paths:
78
76
  - lib
@@ -80,15 +78,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
80
78
  requirements:
81
79
  - - ">="
82
80
  - !ruby/object:Gem::Version
83
- version: '0'
81
+ version: 3.4.0
84
82
  required_rubygems_version: !ruby/object:Gem::Requirement
85
83
  requirements:
86
84
  - - ">="
87
85
  - !ruby/object:Gem::Version
88
86
  version: '0'
89
87
  requirements: []
90
- rubygems_version: 3.4.10
91
- signing_key:
88
+ rubygems_version: 3.6.7
92
89
  specification_version: 4
93
90
  summary: Counters and the counting counters that count them
94
91
  test_files: []