counter_culture 3.5.3 → 3.7.0

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: 7c2646918dbfacac2d3d3f923c601111bfcf58b46b1ba0ed035e9f8a557b9577
4
- data.tar.gz: 52cd080e6eb8beb589c8cfb52e73ad62fe333caea4c91d9098cbb5a78ff8f30f
3
+ metadata.gz: 88687e8c584a1643026e8e09d9f64484110fd7a19ebf1faa6cb311361e8c35ca
4
+ data.tar.gz: a81ac645489129472e265f3bfefa0573f11a9e8419a40ba7f4c687c692367177
5
5
  SHA512:
6
- metadata.gz: b8736342d26f93e1cba9372057d941b216efda9bcefe8f56215b001b987e0a557f82e632376879a9006bf2b1d58cf876682f23efe7f6fb3912232d8de58442ea
7
- data.tar.gz: a92f08b7574babb1c2e79b634b3f418f3e37e7d8b83abbddd8d1d9e909e4c073034b1f1b4f5534de5d9136967dc1863fcfec0a2f49e1c7620930d5c5ece617e9
6
+ metadata.gz: 82ffa1af6e6ad80f68a56b77cdae1f68dd30aa63535281f4ee19ee3ad1e5f88cc7053ae4cab6f1786db7038baeb81cf5c434b1b657f886b62862d79b0d99d7dc
7
+ data.tar.gz: 83e5c626c97583727a5cbef6c824dd5eb25625d169a53e65e73bd1d2ab633121bb2a1a2de430a2d522caa92382b5852d731e0f954818fa906ab2b0060986119a
data/Appraisals CHANGED
@@ -7,5 +7,8 @@
7
7
  ].each do |rails_version|
8
8
  appraise "rails-#{rails_version}" do
9
9
  gem 'rails', "~> #{rails_version}.0"
10
+ if Gem::Version.new(rails_version) < Gem::Version.new("7.2")
11
+ gem 'sqlite3', "~> 1.4"
12
+ end
10
13
  end
11
14
  end
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 3.7.0 (June 20, 2024)
2
+
3
+ New features:
4
+ - Combine multiple updates into a single SQL per target row (#393)
5
+
6
+ ## 3.6.0 (June 18, 2024)
7
+
8
+ Improvements:
9
+ - Don't perform a query if the `delta_magnitude` is zero (#394)
10
+
1
11
  ## 3.5.3 (February 16, 2024)
2
12
 
3
13
  Bugfixes:
data/README.md CHANGED
@@ -285,6 +285,108 @@ You can also pass a `Proc` for dynamic control. This is useful for temporarily m
285
285
  counter_culture :category, execute_after_commit: proc { !Thread.current[:update_counter_cache_in_transaction] }
286
286
  ```
287
287
 
288
+ ### Aggregating multiple updates into single SQL queries
289
+
290
+ > **NOTE**: This does not have an effect on Papertrail callbacks
291
+
292
+ By default, every create/update/destroy will trigger separate `UPDATE` SQLs for each action and targeted column. For example, if the creation of 1 record needs to increment 3 counter cache columns, a transaction that creates 3 records of this kind will generate 9 `UPDATE` SQL queries adding `+1` to the value of said columns (3 created records x 3 counter cache columns to be incremented by each).
293
+
294
+ To avoid this, you can wrap the logic that creates/updates/destroys multiple records in `CounterCulture.aggregate_counter_updates`. This will sum all updates for counter cache columns and will take the latest value for timestamp updates. As a result, you will get just one `UPDATE` SQL query per target record that updates all of its columns at once.
295
+
296
+ #### Execute aggregated SQL queries before transaction `COMMIT`
297
+ ```ruby
298
+ ActiveRecord::Base.transaction do
299
+ CounterCulture.aggregate_counter_updates do
300
+ # list of updates
301
+ end # => executes aggregated SQLs
302
+ end
303
+ ```
304
+
305
+ #### Execute aggregated SQL queries after transaction `COMMIT`
306
+ ```ruby
307
+ CounterCulture.aggregate_counter_updates do
308
+ ActiveRecord::Base.transaction do
309
+ # list of updates
310
+ end
311
+ end # => executes aggregated SQLs
312
+ ```
313
+
314
+ #### Examples:
315
+ <details><summary>Sums multiple counter updates for single column of a single target record</summary>
316
+
317
+ ```sql
318
+ -- Before
319
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 1
320
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 1
321
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 1
322
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
323
+
324
+ -- After
325
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 2 WHERE `authors`.`id` = 1
326
+ ```
327
+
328
+ ```sql
329
+ -- Before
330
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
331
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
332
+
333
+ -- After
334
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + -2 WHERE `authors`.`id` = 1
335
+ ```
336
+
337
+ ```sql
338
+ -- Before
339
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 1
340
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
341
+
342
+ -- After
343
+ No query
344
+ ```
345
+
346
+ </details>
347
+
348
+ <details><summary>Combines multiple updates for multiple columns of a single target record</summary>
349
+
350
+ ```sql
351
+ -- Before
352
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1, updated_at = '2024-06-06 01:00:00' WHERE `authors`.`id` = 1
353
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1, updated_at = '2024-06-06 02:00:00' WHERE `authors`.`id` = 1
354
+
355
+ -- After
356
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 2, updated_at = '2024-06-06 02:00:00' WHERE `authors`.`id` = 1
357
+ ```
358
+
359
+ ```sql
360
+ -- Before
361
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 1
362
+ UPDATE `authors` SET `authors`.`works_count` = COALESCE(`authors`.`works_count`, 0) + 1 WHERE `authors`.`id` = 1
363
+
364
+ -- After
365
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1, `authors`.`works_count` = COALESCE(`authors`.`works_count`, 0) + 1 WHERE `authors`.`id` = 1
366
+ ```
367
+
368
+ </details>
369
+
370
+ </details>
371
+
372
+ <details><summary>Combines multiple updates of multiple target records</summary>
373
+
374
+ ```sql
375
+ -- Before
376
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
377
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
378
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) - 1 WHERE `authors`.`id` = 1
379
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 2
380
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 2
381
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 1 WHERE `authors`.`id` = 2
382
+
383
+ -- After
384
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + -3 WHERE `authors`.`id` = 1
385
+ UPDATE `authors` SET `authors`.`books_count` = COALESCE(`authors`.`books_count`, 0) + 3 WHERE `authors`.`id` = 2
386
+ ```
387
+
388
+ </details>
389
+
288
390
  ### Manually populating counter cache values
289
391
 
290
392
  You will sometimes want to populate counter-cache values from primary data. This is required when adding counter-caches to existing data. It is also recommended to run this regularly (at BestVendor, we run it once a week) to catch any incorrect values in the counter caches.
@@ -43,7 +43,7 @@ Gem::Specification.new do |spec|
43
43
  spec.add_development_dependency 'rspec-extra-formatters'
44
44
  spec.add_development_dependency 'simplecov', '~> 0.16.1'
45
45
  spec.add_development_dependency 'timecop'
46
- spec.add_development_dependency 'sqlite3'
46
+ spec.add_development_dependency 'sqlite3', ">= 1.4"
47
47
  spec.add_development_dependency 'mysql2'
48
48
  spec.add_development_dependency 'pg'
49
49
  end
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 5.2.0"
6
+ gem "sqlite3", "~> 1.4"
6
7
 
7
8
  gemspec path: "../"
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 6.0.0"
6
+ gem "sqlite3", "~> 1.4"
6
7
 
7
8
  gemspec path: "../"
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 6.1.0"
6
+ gem "sqlite3", "~> 1.4"
6
7
 
7
8
  gemspec path: "../"
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 7.0.0"
6
+ gem "sqlite3", "~> 1.4"
6
7
 
7
8
  gemspec path: "../"
@@ -3,5 +3,6 @@
3
3
  source "https://rubygems.org"
4
4
 
5
5
  gem "rails", "~> 7.1.0"
6
+ gem "sqlite3", "~> 1.4"
6
7
 
7
8
  gemspec path: "../"
@@ -55,6 +55,8 @@ module CounterCulture
55
55
  else
56
56
  counter_delta_magnitude_for(obj)
57
57
  end
58
+ return if delta_magnitude.zero?
59
+
58
60
  # increment or decrement?
59
61
  operator = options[:increment] ? '+' : '-'
60
62
 
@@ -73,13 +75,15 @@ module CounterCulture
73
75
 
74
76
  # we don't use Rails' update_counters because we support changing the timestamp
75
77
  updates = []
76
- # this updates the actual counter
77
- if column_type == :money
78
- updates << "#{quoted_column} = COALESCE(CAST(#{quoted_column} as NUMERIC), 0) #{operator} #{delta_magnitude}"
78
+
79
+ # this will update the actual counter
80
+ updates << if column_type == :money
81
+ assemble_money_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
79
82
  else
80
- updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
83
+ assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
81
84
  end
82
- # and here we update the timestamp, if so desired
85
+
86
+ # and this will update the timestamp, if so desired
83
87
  if touch
84
88
  current_time = klass.send(:current_time_from_proper_timezone)
85
89
  timestamp_columns = klass.send(:timestamp_attributes_for_update_in_model)
@@ -89,12 +93,21 @@ module CounterCulture
89
93
  timestamp_columns << touch
90
94
  end
91
95
  timestamp_columns.each do |timestamp_column|
92
- updates << "#{timestamp_column} = '#{current_time.to_formatted_s(:db)}'"
96
+ updates << assemble_timestamp_update(
97
+ klass,
98
+ id_to_change,
99
+ timestamp_column,
100
+ -> { klass.send(:current_time_from_proper_timezone).to_formatted_s(:db) }
101
+ )
93
102
  end
94
103
  end
95
104
 
96
105
  primary_key = relation_primary_key(relation, source: obj, was: options[:was])
97
106
 
107
+ if Thread.current[:aggregate_counter_updates]
108
+ Thread.current[:primary_key_map][klass] ||= primary_key
109
+ end
110
+
98
111
  if @with_papertrail
99
112
  instance = klass.where(primary_key => id_to_change).first
100
113
  if instance
@@ -118,8 +131,10 @@ module CounterCulture
118
131
  end
119
132
  end
120
133
 
121
- execute_now_or_after_commit(obj) do
122
- klass.where(primary_key => id_to_change).update_all updates.join(', ')
134
+ unless Thread.current[:aggregate_counter_updates]
135
+ execute_now_or_after_commit(obj) do
136
+ klass.where(primary_key => id_to_change).update_all updates.join(', ')
137
+ end
123
138
  end
124
139
  end
125
140
  end
@@ -165,7 +180,7 @@ module CounterCulture
165
180
  def foreign_key_value(obj, relation, was = false)
166
181
  original_relation = relation
167
182
  relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
168
-
183
+
169
184
  if was
170
185
  first = relation.shift
171
186
  foreign_key_value = attribute_was(obj, relation_foreign_key(first))
@@ -333,6 +348,7 @@ module CounterCulture
333
348
  end
334
349
 
335
350
  private
351
+
336
352
  def attribute_was(obj, attr)
337
353
  changes_method =
338
354
  if ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0")
@@ -342,5 +358,63 @@ module CounterCulture
342
358
  end
343
359
  obj.public_send("#{attr}#{changes_method}")
344
360
  end
361
+
362
+ def assemble_money_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
363
+ counter_update_snippet(
364
+ "#{quoted_column} = COALESCE(CAST(#{quoted_column} as NUMERIC), 0)",
365
+ klass,
366
+ id_to_change,
367
+ operator,
368
+ delta_magnitude
369
+ )
370
+ end
371
+
372
+ def assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
373
+ counter_update_snippet(
374
+ "#{quoted_column} = COALESCE(#{quoted_column}, 0)",
375
+ klass,
376
+ id_to_change,
377
+ operator,
378
+ delta_magnitude
379
+ )
380
+ end
381
+
382
+ def assemble_timestamp_update(klass, id_to_change, timestamp_column, value)
383
+ update = "#{timestamp_column} ="
384
+
385
+ if Thread.current[:aggregate_counter_updates]
386
+ remember_timestamp_update(klass, id_to_change, update, value)
387
+ else
388
+ "#{update} '#{value.call}'"
389
+ end
390
+ end
391
+
392
+ def counter_update_snippet(update, klass, id_to_change, operator, delta_magnitude)
393
+ if Thread.current[:aggregate_counter_updates]
394
+ remember_counter_update(
395
+ klass,
396
+ id_to_change,
397
+ "#{update} +",
398
+ operator == '+' ? delta_magnitude : -delta_magnitude
399
+ )
400
+ else
401
+ "#{update} #{operator} #{delta_magnitude}"
402
+ end
403
+ end
404
+
405
+ def remember_counter_update(klass, id, operation, value)
406
+ Thread.current[:aggregated_updates][klass] ||= {}
407
+ Thread.current[:aggregated_updates][klass][id] ||= {}
408
+ Thread.current[:aggregated_updates][klass][id][operation] ||= 0
409
+
410
+ Thread.current[:aggregated_updates][klass][id][operation] += value
411
+ end
412
+
413
+ def remember_timestamp_update(klass, id, operation, value)
414
+ Thread.current[:aggregated_updates][klass] ||= {}
415
+ Thread.current[:aggregated_updates][klass][id] ||= {}
416
+
417
+ Thread.current[:aggregated_updates][klass][id][operation] = value
418
+ end
345
419
  end
346
420
  end
@@ -1,3 +1,3 @@
1
1
  module CounterCulture
2
- VERSION = '3.5.3'.freeze
2
+ VERSION = '3.7.0'.freeze
3
3
  end
@@ -15,6 +15,38 @@ module CounterCulture
15
15
  yield(self) if block_given?
16
16
  self
17
17
  end
18
+
19
+ def self.aggregate_counter_updates
20
+ return unless block_given?
21
+
22
+ Thread.current[:aggregate_counter_updates] = true
23
+ Thread.current[:aggregated_updates] = {}
24
+ Thread.current[:primary_key_map] = {}
25
+
26
+ result = yield
27
+
28
+ # aggregate the updates for each target record and execute SQL queries
29
+ Thread.current[:aggregated_updates].each do |klass, attrs|
30
+ attrs.each do |rec_id, updates|
31
+ update_snippets = updates.map do |operation, value|
32
+ value = value.call if value.is_a?(Proc)
33
+ %Q{#{operation} #{value.is_a?(String) ? "'#{value}'" : value}} unless value == 0
34
+ end.compact
35
+
36
+ if update_snippets.any?
37
+ klass
38
+ .where(Thread.current[:primary_key_map][klass] => rec_id)
39
+ .update_all(update_snippets.join(', '))
40
+ end
41
+ end
42
+ end
43
+
44
+ result
45
+ ensure
46
+ Thread.current[:aggregate_counter_updates] = false
47
+ Thread.current[:aggregated_updates] = nil
48
+ Thread.current[:primary_key_map] = nil
49
+ end
18
50
  end
19
51
 
20
52
  # extend ActiveRecord with our own code here
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: counter_culture
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.5.3
4
+ version: 3.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Magnus von Koeller
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-02-17 00:00:00.000000000 Z
11
+ date: 2024-06-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -254,14 +254,14 @@ dependencies:
254
254
  requirements:
255
255
  - - ">="
256
256
  - !ruby/object:Gem::Version
257
- version: '0'
257
+ version: '1.4'
258
258
  type: :development
259
259
  prerelease: false
260
260
  version_requirements: !ruby/object:Gem::Requirement
261
261
  requirements:
262
262
  - - ">="
263
263
  - !ruby/object:Gem::Version
264
- version: '0'
264
+ version: '1.4'
265
265
  - !ruby/object:Gem::Dependency
266
266
  name: mysql2
267
267
  requirement: !ruby/object:Gem::Requirement
@@ -345,7 +345,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
345
345
  - !ruby/object:Gem::Version
346
346
  version: '0'
347
347
  requirements: []
348
- rubygems_version: 3.4.19
348
+ rubygems_version: 3.5.10
349
349
  signing_key:
350
350
  specification_version: 4
351
351
  summary: Turbo-charged counter caches for your Rails app.