counter_culture 3.6.0 → 3.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f4fd84b17129a9385e310381e0a19d5dc94a77f99bc6375e296cbf88c08fe72
4
- data.tar.gz: a717e7ce645837b369e9fa5d40f7e721efaa8815b0ece0d814e124b798ede467
3
+ metadata.gz: 88687e8c584a1643026e8e09d9f64484110fd7a19ebf1faa6cb311361e8c35ca
4
+ data.tar.gz: a81ac645489129472e265f3bfefa0573f11a9e8419a40ba7f4c687c692367177
5
5
  SHA512:
6
- metadata.gz: 74b120975400617101789ac7721b839e57e210e68a28d1edc307e2b8e03086e0d6adc652cb9a96be7cd0a7921c7e8033216316d63f0c74da9e3b929a9d24ad39
7
- data.tar.gz: 050d42b195d1c947aa79f8cbbaf2dea74441020c67b96aa2493463c6cdcf0a1ccb649b7a322921174a64a30f4ba9287a0450ab217915dd62844adafdc5f934cd
6
+ metadata.gz: 82ffa1af6e6ad80f68a56b77cdae1f68dd30aa63535281f4ee19ee3ad1e5f88cc7053ae4cab6f1786db7038baeb81cf5c434b1b657f886b62862d79b0d99d7dc
7
+ data.tar.gz: 83e5c626c97583727a5cbef6c824dd5eb25625d169a53e65e73bd1d2ab633121bb2a1a2de430a2d522caa92382b5852d731e0f954818fa906ab2b0060986119a
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 3.7.0 (June 20, 2024)
2
+
3
+ New features:
4
+ - Combine multiple updates into a single SQL per target row (#393)
5
+
1
6
  ## 3.6.0 (June 18, 2024)
2
7
 
3
8
  Improvements:
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.
@@ -75,13 +75,15 @@ module CounterCulture
75
75
 
76
76
  # we don't use Rails' update_counters because we support changing the timestamp
77
77
  updates = []
78
- # this updates the actual counter
79
- if column_type == :money
80
- 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)
81
82
  else
82
- updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
83
+ assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
83
84
  end
84
- # and here we update the timestamp, if so desired
85
+
86
+ # and this will update the timestamp, if so desired
85
87
  if touch
86
88
  current_time = klass.send(:current_time_from_proper_timezone)
87
89
  timestamp_columns = klass.send(:timestamp_attributes_for_update_in_model)
@@ -91,12 +93,21 @@ module CounterCulture
91
93
  timestamp_columns << touch
92
94
  end
93
95
  timestamp_columns.each do |timestamp_column|
94
- 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
+ )
95
102
  end
96
103
  end
97
104
 
98
105
  primary_key = relation_primary_key(relation, source: obj, was: options[:was])
99
106
 
107
+ if Thread.current[:aggregate_counter_updates]
108
+ Thread.current[:primary_key_map][klass] ||= primary_key
109
+ end
110
+
100
111
  if @with_papertrail
101
112
  instance = klass.where(primary_key => id_to_change).first
102
113
  if instance
@@ -120,8 +131,10 @@ module CounterCulture
120
131
  end
121
132
  end
122
133
 
123
- execute_now_or_after_commit(obj) do
124
- 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
125
138
  end
126
139
  end
127
140
  end
@@ -167,7 +180,7 @@ module CounterCulture
167
180
  def foreign_key_value(obj, relation, was = false)
168
181
  original_relation = relation
169
182
  relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
170
-
183
+
171
184
  if was
172
185
  first = relation.shift
173
186
  foreign_key_value = attribute_was(obj, relation_foreign_key(first))
@@ -335,6 +348,7 @@ module CounterCulture
335
348
  end
336
349
 
337
350
  private
351
+
338
352
  def attribute_was(obj, attr)
339
353
  changes_method =
340
354
  if ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0")
@@ -344,5 +358,63 @@ module CounterCulture
344
358
  end
345
359
  obj.public_send("#{attr}#{changes_method}")
346
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
347
419
  end
348
420
  end
@@ -1,3 +1,3 @@
1
1
  module CounterCulture
2
- VERSION = '3.6.0'.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.6.0
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-06-18 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