counter_culture 3.6.0 → 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: 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