activerecord-slotted_counters 0.0.1 → 0.1.1

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: 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