counter_culture 3.6.0 → 3.8.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: 225d25bff8cd9226371b162e6761603271cbac02c74be1dd7398de2dc63dbb71
4
+ data.tar.gz: ea2b215508e285bab75efcbf3aeb153a15458c22db0cc0b79350bb2e9b3a9e05
5
5
  SHA512:
6
- metadata.gz: 74b120975400617101789ac7721b839e57e210e68a28d1edc307e2b8e03086e0d6adc652cb9a96be7cd0a7921c7e8033216316d63f0c74da9e3b929a9d24ad39
7
- data.tar.gz: 050d42b195d1c947aa79f8cbbaf2dea74441020c67b96aa2493463c6cdcf0a1ccb649b7a322921174a64a30f4ba9287a0450ab217915dd62844adafdc5f934cd
6
+ metadata.gz: 5362e9b1db5c4a81944e3deeddec9c7945ae349cca9f663dae140fb1516f00b9bd41d4d34af4705c2d4600ee65dabe2a41bc0a53e1a98ba682385228b0f4e41a
7
+ data.tar.gz: 60d99dab523e3f339961c4d19393f27de41006c361dc190baf25676157dded5b98382d77cca3dd76533cb070e3eec14965bb90ffe3f13991ba6d409cdf3e779a
data/.circleci/config.yml CHANGED
@@ -49,7 +49,7 @@ workflows:
49
49
  matrix:
50
50
  parameters:
51
51
  ruby-version: ["2.6", "2.7", "3.0", "3.1", "3.2", "3.3"]
52
- rails-version: ["5.2", "6.0", "6.1", "7.0", "7.1"]
52
+ rails-version: ["5.2", "6.0", "6.1", "7.0", "7.1", "7.2"]
53
53
  database: ["postgresql", "sqlite3", "mysql2"]
54
54
  exclude:
55
55
  - ruby-version: "3.0"
@@ -106,3 +106,30 @@ workflows:
106
106
  - ruby-version: "2.6"
107
107
  rails-version: "7.1"
108
108
  database: "mysql2"
109
+ - ruby-version: "2.6"
110
+ rails-version: "7.2"
111
+ database: "postgresql"
112
+ - ruby-version: "2.6"
113
+ rails-version: "7.2"
114
+ database: "sqlite3"
115
+ - ruby-version: "2.6"
116
+ rails-version: "7.2"
117
+ database: "mysql2"
118
+ - ruby-version: "2.7"
119
+ rails-version: "7.2"
120
+ database: "postgresql"
121
+ - ruby-version: "2.7"
122
+ rails-version: "7.2"
123
+ database: "sqlite3"
124
+ - ruby-version: "2.7"
125
+ rails-version: "7.2"
126
+ database: "mysql2"
127
+ - ruby-version: "3.0"
128
+ rails-version: "7.2"
129
+ database: "postgresql"
130
+ - ruby-version: "3.0"
131
+ rails-version: "7.2"
132
+ database: "sqlite3"
133
+ - ruby-version: "3.0"
134
+ rails-version: "7.2"
135
+ database: "mysql2"
data/Appraisals CHANGED
@@ -4,6 +4,7 @@
4
4
  6.1
5
5
  7.0
6
6
  7.1
7
+ 7.2
7
8
  ].each do |rails_version|
8
9
  appraise "rails-#{rails_version}" do
9
10
  gem 'rails', "~> #{rails_version}.0"
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 3.8.0 (October 4, 2024)
2
+
3
+ New features:
4
+ - Prefer using `with_connection` where possible (#398)
5
+
6
+ ## 3.7.0 (June 20, 2024)
7
+
8
+ New features:
9
+ - Combine multiple updates into a single SQL per target row (#393)
10
+
1
11
  ## 3.6.0 (June 18, 2024)
2
12
 
3
13
  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.
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 7.2.0"
6
+
7
+ gemspec path: "../"
@@ -1,3 +1,5 @@
1
+ require_relative './with_connection'
2
+
1
3
  module CounterCulture
2
4
  class Counter
3
5
  CONFIG_OPTIONS = [ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch, :delta_magnitude, :execute_after_commit ]
@@ -65,23 +67,27 @@ module CounterCulture
65
67
  # MySQL throws an ambiguous column error if any joins are present and we don't include the
66
68
  # table name. We isolate this change to MySQL because sqlite has the opposite behavior and
67
69
  # throws an exception if the table name is present after UPDATE.
68
- quoted_column = if klass.connection.adapter_name == 'Mysql2'
69
- "#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
70
- else
71
- "#{model.connection.quote_column_name(change_counter_column)}"
72
- end
70
+ quoted_column = WithConnection.new(klass).call do |connection|
71
+ if connection.adapter_name == 'Mysql2'
72
+ "#{klass.quoted_table_name}.#{connection.quote_column_name(change_counter_column)}"
73
+ else
74
+ "#{connection.quote_column_name(change_counter_column)}"
75
+ end
76
+ end
73
77
 
74
78
  column_type = klass.type_for_attribute(change_counter_column).type
75
79
 
76
80
  # we don't use Rails' update_counters because we support changing the timestamp
77
81
  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}"
82
+
83
+ # this will update the actual counter
84
+ updates << if column_type == :money
85
+ assemble_money_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
81
86
  else
82
- updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
87
+ assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
83
88
  end
84
- # and here we update the timestamp, if so desired
89
+
90
+ # and this will update the timestamp, if so desired
85
91
  if touch
86
92
  current_time = klass.send(:current_time_from_proper_timezone)
87
93
  timestamp_columns = klass.send(:timestamp_attributes_for_update_in_model)
@@ -91,12 +97,21 @@ module CounterCulture
91
97
  timestamp_columns << touch
92
98
  end
93
99
  timestamp_columns.each do |timestamp_column|
94
- updates << "#{timestamp_column} = '#{current_time.to_formatted_s(:db)}'"
100
+ updates << assemble_timestamp_update(
101
+ klass,
102
+ id_to_change,
103
+ timestamp_column,
104
+ -> { klass.send(:current_time_from_proper_timezone).to_formatted_s(:db) }
105
+ )
95
106
  end
96
107
  end
97
108
 
98
109
  primary_key = relation_primary_key(relation, source: obj, was: options[:was])
99
110
 
111
+ if Thread.current[:aggregate_counter_updates]
112
+ Thread.current[:primary_key_map][klass] ||= primary_key
113
+ end
114
+
100
115
  if @with_papertrail
101
116
  instance = klass.where(primary_key => id_to_change).first
102
117
  if instance
@@ -120,8 +135,10 @@ module CounterCulture
120
135
  end
121
136
  end
122
137
 
123
- execute_now_or_after_commit(obj) do
124
- klass.where(primary_key => id_to_change).update_all updates.join(', ')
138
+ unless Thread.current[:aggregate_counter_updates]
139
+ execute_now_or_after_commit(obj) do
140
+ klass.where(primary_key => id_to_change).update_all updates.join(', ')
141
+ end
125
142
  end
126
143
  end
127
144
  end
@@ -167,7 +184,7 @@ module CounterCulture
167
184
  def foreign_key_value(obj, relation, was = false)
168
185
  original_relation = relation
169
186
  relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
170
-
187
+
171
188
  if was
172
189
  first = relation.shift
173
190
  foreign_key_value = attribute_was(obj, relation_foreign_key(first))
@@ -335,6 +352,7 @@ module CounterCulture
335
352
  end
336
353
 
337
354
  private
355
+
338
356
  def attribute_was(obj, attr)
339
357
  changes_method =
340
358
  if ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0")
@@ -344,5 +362,63 @@ module CounterCulture
344
362
  end
345
363
  obj.public_send("#{attr}#{changes_method}")
346
364
  end
365
+
366
+ def assemble_money_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
367
+ counter_update_snippet(
368
+ "#{quoted_column} = COALESCE(CAST(#{quoted_column} as NUMERIC), 0)",
369
+ klass,
370
+ id_to_change,
371
+ operator,
372
+ delta_magnitude
373
+ )
374
+ end
375
+
376
+ def assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
377
+ counter_update_snippet(
378
+ "#{quoted_column} = COALESCE(#{quoted_column}, 0)",
379
+ klass,
380
+ id_to_change,
381
+ operator,
382
+ delta_magnitude
383
+ )
384
+ end
385
+
386
+ def assemble_timestamp_update(klass, id_to_change, timestamp_column, value)
387
+ update = "#{timestamp_column} ="
388
+
389
+ if Thread.current[:aggregate_counter_updates]
390
+ remember_timestamp_update(klass, id_to_change, update, value)
391
+ else
392
+ "#{update} '#{value.call}'"
393
+ end
394
+ end
395
+
396
+ def counter_update_snippet(update, klass, id_to_change, operator, delta_magnitude)
397
+ if Thread.current[:aggregate_counter_updates]
398
+ remember_counter_update(
399
+ klass,
400
+ id_to_change,
401
+ "#{update} +",
402
+ operator == '+' ? delta_magnitude : -delta_magnitude
403
+ )
404
+ else
405
+ "#{update} #{operator} #{delta_magnitude}"
406
+ end
407
+ end
408
+
409
+ def remember_counter_update(klass, id, operation, value)
410
+ Thread.current[:aggregated_updates][klass] ||= {}
411
+ Thread.current[:aggregated_updates][klass][id] ||= {}
412
+ Thread.current[:aggregated_updates][klass][id][operation] ||= 0
413
+
414
+ Thread.current[:aggregated_updates][klass][id][operation] += value
415
+ end
416
+
417
+ def remember_timestamp_update(klass, id, operation, value)
418
+ Thread.current[:aggregated_updates][klass] ||= {}
419
+ Thread.current[:aggregated_updates][klass][id] ||= {}
420
+
421
+ Thread.current[:aggregated_updates][klass][id][operation] = value
422
+ end
347
423
  end
348
424
  end
@@ -1,6 +1,8 @@
1
1
  require 'active_support/core_ext/module/delegation'
2
2
  require 'active_support/core_ext/module/attribute_accessors'
3
3
 
4
+ require_relative './with_connection'
5
+
4
6
  module CounterCulture
5
7
  class Reconciler
6
8
  ACTIVE_RECORD_VERSION = Gem.loaded_specs["activerecord"].version
@@ -312,7 +314,9 @@ module CounterCulture
312
314
  # using Postgres with schema-namespaced tables. But then it's required,
313
315
  # and otherwise it's just a no-op, so why not do it?
314
316
  def quote_table_name(table_name)
315
- relation_class.connection.quote_table_name(table_name)
317
+ WithConnection.new(relation_class).call do |connection|
318
+ connection.quote_table_name(table_name)
319
+ end
316
320
  end
317
321
 
318
322
  def parameterize(string)
@@ -1,3 +1,3 @@
1
1
  module CounterCulture
2
- VERSION = '3.6.0'.freeze
2
+ VERSION = '3.8.0'.freeze
3
3
  end
@@ -0,0 +1,33 @@
1
+ module CounterCulture
2
+ class WithConnection
3
+ def initialize(recipient)
4
+ @recipient = recipient
5
+ end
6
+
7
+ attr_reader :recipient
8
+
9
+ def call
10
+ if rails_7_2_or_greater?
11
+ recipient.with_connection do |connection|
12
+ yield connection
13
+ end
14
+ elsif rails_7_1?
15
+ recipient.connection_pool.with_connection do |connection|
16
+ yield connection
17
+ end
18
+ else
19
+ yield recipient.connection
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def rails_7_1?
26
+ Gem::Requirement.new('~> 7.1.0').satisfied_by?(Gem::Version.new(Rails.version))
27
+ end
28
+
29
+ def rails_7_2_or_greater?
30
+ Gem::Requirement.new('>= 7.2.0').satisfied_by?(Gem::Version.new(Rails.version))
31
+ end
32
+ end
33
+ 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.8.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-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -318,12 +318,14 @@ files:
318
318
  - gemfiles/rails_6.1.gemfile
319
319
  - gemfiles/rails_7.0.gemfile
320
320
  - gemfiles/rails_7.1.gemfile
321
+ - gemfiles/rails_7.2.gemfile
321
322
  - lib/counter_culture.rb
322
323
  - lib/counter_culture/counter.rb
323
324
  - lib/counter_culture/extensions.rb
324
325
  - lib/counter_culture/reconciler.rb
325
326
  - lib/counter_culture/skip_updates.rb
326
327
  - lib/counter_culture/version.rb
328
+ - lib/counter_culture/with_connection.rb
327
329
  - lib/generators/counter_culture_generator.rb
328
330
  - lib/generators/templates/counter_culture_migration.rb.erb
329
331
  homepage: https://github.com/magnusvk/counter_culture