activerecord-slotted_counters 0.0.1 → 0.1.1

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: 8f3309ca0e6671a6dbb844a5e3c7e8f0bf289b6125e9cbbed9d8234ed2afb596
4
- data.tar.gz: be5e21d8aea9e6dc73a0150bed7cf13f349a6af7dc71cbe38cc86bd0ed785e3c
3
+ metadata.gz: 5dcee16f487cc8b48e6fb4c4b84069cc97b9647d5e710fa8d8c2e25038620815
4
+ data.tar.gz: 43b9d1432b7aa8088c1508038d8927cf4aca1e8e33e3a159a3b915b7037f8084
5
5
  SHA512:
6
- metadata.gz: 5ec60f4a1eaeff23861b5c1fe7ba6ea2ab866097f2f3d2ced182fcf36c82b34d402a536f37567423f3cdb4b9fe7baa5ca3d434d99fe2235adaeb30ebc0348493
7
- data.tar.gz: fcee85f67d01bc7a4938fc7b261962157ee96d7db69e65003f9091fcffc9d6776fc3b905f6efb6516aeeaa95ae2ac104826671b00e030948f90675dcee4f20c8
6
+ metadata.gz: 9783f3d0166756156a12e2337539acca146ebb8cbec1298d3b3b06a8721c2a23c18e61bc38c2944cd2fb999f798129e30ef608c4f862fad4e3d7628573cd49d3
7
+ data.tar.gz: a6a250dd5daed59fd0495954c9220e02de57e99232d2e19f7e159d9f838d4ad6b2a90acc3ece9f466a829afc99467ec4ef096130827be9d3ba13197c665cb367
data/CHANGELOG.md CHANGED
@@ -2,4 +2,14 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 0.1.1 (2023-01-17)
6
+
7
+ - Fix prevent double increment/decrement of native counter caches [#10](https://github.com/evilmartians/activerecord-slotted_counters/pull/10) ([@danielwestendorf][])
8
+
9
+ ## 0.1.0 (2022-11-29)
10
+
11
+ - Initial release.
12
+
5
13
  [@palkan]: https://github.com/palkan
14
+ [@LukinEgor]: https://github.com/LukinEgor
15
+ [@danielwestendorf]: https://github.com/danielwestendorf
data/README.md CHANGED
@@ -2,10 +2,17 @@
2
2
 
3
3
  # Active Record slotted counters
4
4
 
5
- This gem adds **slotted counters** support to [Active Record counter cache][counter-cache]. Slotted counters help to reduce contention on a single row update in case you many concurrent operations (like updating a page views counter during traffic spikes).
5
+ This gem adds **slotted counters** support to [Active Record counter cache][counter-cache]. Slotted counters help to reduce contention on a single row update in case you have many concurrent operations (like updating a page views counter during traffic spikes).
6
6
 
7
7
  Read more about slotted counters in [this post](https://planetscale.com/blog/the-slotted-counter-pattern).
8
8
 
9
+ <p align="center">
10
+ <a href="https://evilmartians.com/?utm_source=active-record-slotted-counters">
11
+ <img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg"
12
+ alt="Sponsored by Evil Martians" width="236" height="54">
13
+ </a>
14
+ </p>
15
+
9
16
  ## Installation
10
17
 
11
18
  Add to your project:
@@ -56,11 +63,11 @@ Under the hood, a row in the `slotted_counters` table is created associated with
56
63
  **NOTE:** Reading the current value performs SQL query once:
57
64
 
58
65
  ```ruby
59
- user.comments_count #=> select count from slotted_counters where ...
66
+ user.comments_count #=> select * from slotted_counters where ...
60
67
  user.comments_count #=> no sql
61
68
  ```
62
69
 
63
- If you want to want preload counters for multiple records, you can use a convinient `#with_slotted_counters` method:
70
+ If you want to want preload counters for multiple records, you can use a convenient `#with_slotted_counters` method:
64
71
 
65
72
  ```ruby
66
73
  User.all.with_slotted_counters(:comments).find_each do
@@ -72,10 +79,6 @@ Using `counter_cache: true` on `belongs_to` associations also works as expected.
72
79
 
73
80
  ## Limitations / TODO
74
81
 
75
- - Add `reset_counters` implementation
76
- - Add `update_counters` implementation
77
- - Add `with_slotted_counters` scope
78
- - Add multiple `has_slotted_counter` support
79
82
  - Rails 6 support
80
83
 
81
84
  ## Contributing
@@ -10,6 +10,15 @@ module ActiveRecordSlottedCounters
10
10
  end
11
11
  end
12
12
 
13
+ module BelongsToAssociation
14
+ def update_counters_via_scope(klass, foreign_key, by)
15
+ counter_name = reflection.counter_cache_column
16
+ return super unless klass.registered_slotted_counter? counter_name
17
+
18
+ klass.update_counters(foreign_key, counter_name => by, :touch => reflection.options[:touch])
19
+ end
20
+ end
21
+
13
22
  module HasSlottedCounter
14
23
  extend ActiveSupport::Concern
15
24
  include ActiveRecordSlottedCounters::Utils
@@ -29,7 +38,12 @@ module ActiveRecordSlottedCounters
29
38
  counter_name = slotted_counter_name(counter_type)
30
39
  association_name = slotted_counter_association_name(counter_type)
31
40
 
32
- has_many association_name, **SLOTTED_COUNTERS_ASSOCIATION_OPTIONS
41
+ has_many association_name, ->(model) { associated_records(counter_name, model.id, model.class.to_s) }, **SLOTTED_COUNTERS_ASSOCIATION_OPTIONS
42
+
43
+ scope :with_slotted_counters, ->(counter_type) do
44
+ association_name = slotted_counter_association_name(counter_type)
45
+ preload(association_name)
46
+ end
33
47
 
34
48
  _slotted_counters << counter_type
35
49
 
@@ -38,16 +52,39 @@ module ActiveRecordSlottedCounters
38
52
  end
39
53
  end
40
54
 
41
- def increment_counter(counter_name, id, touch: nil)
42
- return super unless registered_slotted_counter? counter_name
55
+ def update_counters(id, counters)
56
+ touch = counters.delete(:touch)
57
+
58
+ updated_counters_count = 0
59
+ registered_counters, unregistered_counters = counters.partition { |name, _| registered_slotted_counter? name }.map(&:to_h)
60
+
61
+ if unregistered_counters.present?
62
+ unregistered_counters[:touch] = touch
63
+ updated_unregistered_counters_count = super(id, unregistered_counters)
64
+ updated_counters_count += updated_unregistered_counters_count
65
+ end
66
+
67
+ if registered_counters.present?
68
+ ids = Array(id)
69
+ updated_registered_counters_count = update_slotted_counters(ids, registered_counters, touch)
70
+ updated_counters_count += updated_registered_counters_count
71
+ end
43
72
 
44
- insert_counter_record(counter_name, id, 1)
73
+ updated_counters_count
45
74
  end
46
75
 
47
- def decrement_counter(counter_name, id, touch: nil)
48
- return super unless registered_slotted_counter? counter_name
76
+ def reset_counters(id, *counters, touch: nil)
77
+ registered_counters, unregistered_counters = counters.partition { |name| registered_slotted_counter? slotted_counter_name(name) }
49
78
 
50
- insert_counter_record(counter_name, id, -1)
79
+ if unregistered_counters.present?
80
+ super(id, *unregistered_counters, touch: touch)
81
+ end
82
+
83
+ if registered_counters.present?
84
+ reset_slotted_counters(id, *registered_counters, touch: touch)
85
+ end
86
+
87
+ true
51
88
  end
52
89
 
53
90
  def slotted_counters
@@ -58,36 +95,96 @@ module ActiveRecordSlottedCounters
58
95
  end
59
96
  end
60
97
 
98
+ def registered_slotted_counter?(counter_name)
99
+ counter_type = slotted_counter_type(counter_name)
100
+
101
+ slotted_counters.include? counter_type
102
+ end
103
+
104
+ def update_slotted_counters(ids, registered_counters, touch)
105
+ updated_counters_count = 0
106
+ ActiveRecord::Base.transaction do
107
+ updated_counters_count = insert_counters_records(ids, registered_counters)
108
+ touch_attributes(ids, touch) if touch.present?
109
+ end
110
+
111
+ updated_counters_count
112
+ end
113
+
61
114
  private
62
115
 
63
116
  def _slotted_counters
64
117
  @_slotted_counters ||= []
65
118
  end
66
119
 
67
- def registered_slotted_counter?(counter_name)
68
- counter_type = slotted_counter_type(counter_name)
120
+ def reset_slotted_counters(id, *counters, touch: nil)
121
+ object = find(id)
69
122
 
70
- slotted_counters.include? counter_type
123
+ counters.each do |counter_association|
124
+ has_many_association = _reflect_on_association(counter_association)
125
+ raise ArgumentError, "'#{name}' has no association called '#{counter_association}'" unless has_many_association
126
+
127
+ counter_name = slotted_counter_name counter_association
128
+
129
+ ActiveRecord::Base.transaction do
130
+ counter_value = object.send(counter_association).count(:all)
131
+ updates = {counter_name => counter_value}
132
+ remove_counters_records([id], counter_name)
133
+ insert_counters_records([id], updates)
134
+ touch_attributes([id], touch) if touch.present?
135
+ end
136
+ end
71
137
  end
72
138
 
73
- def insert_counter_record(counter_name, id, count)
74
- slot = rand(DEFAULT_MAX_SLOT_NUMBER)
75
- on_duplicate_clause = "count = slotted_counters.count + #{count}"
76
-
77
- result = ActiveRecordSlottedCounters::SlottedCounter.upsert(
78
- {
79
- counter_name: counter_name,
80
- associated_record_type: name,
81
- associated_record_id: id,
82
- slot: slot,
83
- count: count
84
- },
139
+ def insert_counters_records(ids, counters)
140
+ counters_params = prepare_slotted_counters_params(ids, counters)
141
+ on_duplicate_clause = "count = slotted_counters.count + excluded.count"
142
+
143
+ result = ActiveRecordSlottedCounters::SlottedCounter.upsert_all(
144
+ counters_params,
85
145
  on_duplicate: Arel.sql(on_duplicate_clause),
86
146
  unique_by: :index_slotted_counters
87
147
  )
88
148
 
89
149
  result.rows.count
90
150
  end
151
+
152
+ def remove_counters_records(ids, counter_name)
153
+ ActiveRecordSlottedCounters::SlottedCounter.where(
154
+ counter_name: counter_name,
155
+ associated_record_type: name,
156
+ associated_record_id: ids
157
+ ).delete_all
158
+ end
159
+
160
+ def touch_attributes(ids, touch)
161
+ scope = where(id: ids)
162
+ return scope.touch_all if touch == true
163
+
164
+ scope.touch_all(touch)
165
+ end
166
+
167
+ def prepare_slotted_counters_params(ids, counters)
168
+ counters.map do |counter_name, count|
169
+ slot = rand(DEFAULT_MAX_SLOT_NUMBER)
170
+
171
+ ids.map do |id|
172
+ {
173
+ counter_name: counter_name,
174
+ associated_record_type: name,
175
+ associated_record_id: id,
176
+ slot: slot,
177
+ count: count
178
+ }
179
+ end
180
+ end.flatten
181
+ end
182
+ end
183
+
184
+ def increment!(attribute, by = 1, touch: nil)
185
+ return super unless self.class.registered_slotted_counter? attribute
186
+
187
+ self.class.update_counters(id, attribute => by, :touch => touch)
91
188
  end
92
189
 
93
190
  private
@@ -102,8 +199,7 @@ module ActiveRecordSlottedCounters
102
199
  return counter
103
200
  end
104
201
 
105
- counter_name = slotted_counter_name(counter_type)
106
- scope = send(association_name).associated_records(counter_name, id, self.class.to_s)
202
+ scope = send(association_name)
107
203
  scope.sum(:count)
108
204
  end
109
205
  end
@@ -10,6 +10,8 @@ module ActiveRecordSlottedCounters # :nodoc:
10
10
  initializer "extend ActiveRecord with ActiveRecordSlottedCounters" do |_app|
11
11
  ActiveSupport.on_load(:active_record) do
12
12
  ActiveRecord::Base.include ActiveRecordSlottedCounters::HasSlottedCounter
13
+ ActiveRecord::Relation.include ActiveRecordSlottedCounters::Utils
14
+ ActiveRecord::Associations::BelongsToAssociation.prepend ActiveRecordSlottedCounters::BelongsToAssociation
13
15
  end
14
16
  end
15
17
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordSlottedCounters # :nodoc:
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-slotted_counters
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Egor Lukin
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-09-05 00:00:00.000000000 Z
12
+ date: 2023-01-17 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activerecord