counter_culture 3.5.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: acebc4b3981511ea35497a3f276372253cb709314002ab2c6c498fb9b60a0166
4
- data.tar.gz: b659b8956b8e6911c58563e4f921487f49a3ddf8ada5fbee7b79b0de16f8796d
3
+ metadata.gz: 225d25bff8cd9226371b162e6761603271cbac02c74be1dd7398de2dc63dbb71
4
+ data.tar.gz: ea2b215508e285bab75efcbf3aeb153a15458c22db0cc0b79350bb2e9b3a9e05
5
5
  SHA512:
6
- metadata.gz: 9ef804972ebbd99b5945b22d87c85d93dcd3af1ea78f04683a0799b67d25d42cb8c4ca52e08b9267fbd17f11e6fcd07a334e5990cccd5e4fd3ea27991da67fb4
7
- data.tar.gz: 81fa1adc6e283a1fed1a661e08cc7bd4a5280062cb8567ef730ce59342b0cf49032d09a9fc05bcbff713fbbd37d9a7157f2de35bc25df696d48f5de5bed9281c
6
+ metadata.gz: 5362e9b1db5c4a81944e3deeddec9c7945ae349cca9f663dae140fb1516f00b9bd41d4d34af4705c2d4600ee65dabe2a41bc0a53e1a98ba682385228b0f4e41a
7
+ data.tar.gz: 60d99dab523e3f339961c4d19393f27de41006c361dc190baf25676157dded5b98382d77cca3dd76533cb070e3eec14965bb90ffe3f13991ba6d409cdf3e779a
data/.circleci/config.yml CHANGED
@@ -48,8 +48,8 @@ workflows:
48
48
  - test:
49
49
  matrix:
50
50
  parameters:
51
- ruby-version: ["2.6", "2.7", "3.0", "3.1", "3.2"]
52
- rails-version: ["5.2", "6.0", "6.1", "7.0"]
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", "7.2"]
53
53
  database: ["postgresql", "sqlite3", "mysql2"]
54
54
  exclude:
55
55
  - ruby-version: "3.0"
@@ -57,28 +57,37 @@ workflows:
57
57
  database: "postgresql"
58
58
  - ruby-version: "3.0"
59
59
  rails-version: "5.2"
60
- database: "sqlite3"
60
+ database: "mysql2"
61
61
  - ruby-version: "3.0"
62
62
  rails-version: "5.2"
63
- database: "mysql2"
63
+ database: "sqlite3"
64
64
  - ruby-version: "3.1"
65
65
  rails-version: "5.2"
66
66
  database: "postgresql"
67
+ - ruby-version: "3.1"
68
+ rails-version: "5.2"
69
+ database: "mysql2"
67
70
  - ruby-version: "3.1"
68
71
  rails-version: "5.2"
69
72
  database: "sqlite3"
70
73
  - ruby-version: "3.2"
71
74
  rails-version: "5.2"
72
- database: "mysql2"
75
+ database: "postgresql"
73
76
  - ruby-version: "3.2"
74
77
  rails-version: "5.2"
75
- database: "postgresql"
78
+ database: "mysql2"
76
79
  - ruby-version: "3.2"
77
80
  rails-version: "5.2"
78
81
  database: "sqlite3"
79
- - ruby-version: "3.1"
82
+ - ruby-version: "3.3"
83
+ rails-version: "5.2"
84
+ database: "postgresql"
85
+ - ruby-version: "3.3"
80
86
  rails-version: "5.2"
81
87
  database: "mysql2"
88
+ - ruby-version: "3.3"
89
+ rails-version: "5.2"
90
+ database: "sqlite3"
82
91
  - ruby-version: "2.6"
83
92
  rails-version: "7.0"
84
93
  database: "postgresql"
@@ -88,3 +97,39 @@ workflows:
88
97
  - ruby-version: "2.6"
89
98
  rails-version: "7.0"
90
99
  database: "mysql2"
100
+ - ruby-version: "2.6"
101
+ rails-version: "7.1"
102
+ database: "postgresql"
103
+ - ruby-version: "2.6"
104
+ rails-version: "7.1"
105
+ database: "sqlite3"
106
+ - ruby-version: "2.6"
107
+ rails-version: "7.1"
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
@@ -3,8 +3,13 @@
3
3
  6.0
4
4
  6.1
5
5
  7.0
6
+ 7.1
7
+ 7.2
6
8
  ].each do |rails_version|
7
9
  appraise "rails-#{rails_version}" do
8
10
  gem 'rails', "~> #{rails_version}.0"
11
+ if Gem::Version.new(rails_version) < Gem::Version.new("7.2")
12
+ gem 'sqlite3', "~> 1.4"
13
+ end
9
14
  end
10
15
  end
data/CHANGELOG.md CHANGED
@@ -1,3 +1,33 @@
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
+
11
+ ## 3.6.0 (June 18, 2024)
12
+
13
+ Improvements:
14
+ - Don't perform a query if the `delta_magnitude` is zero (#394)
15
+
16
+ ## 3.5.3 (February 16, 2024)
17
+
18
+ Bugfixes:
19
+ - Correct polymorphic table name alias reference in join clauses (#388)
20
+
21
+ ## 3.5.2 (January 16, 2024)
22
+
23
+ Bugfixes:
24
+ - Assign attributes to the duped model on a lower level when determining whether a model has changed to avoid invoking unrelated callbacks (#386)
25
+
26
+ ## 3.5.1 (January 8, 2024)
27
+
28
+ Bugfixes:
29
+ - Fix touching for counted models without `updated_at` (#383)
30
+
1
31
  ## 3.5.0 (August 25, 2023)
2
32
 
3
33
  Improvements:
data/README.md CHANGED
@@ -7,7 +7,7 @@ Turbo-charged counter caches for your Rails app. Huge improvements over the Rail
7
7
  * Supports dynamic column names, making it possible to split up the counter cache for different types of objects
8
8
  * Can keep a running count, or a running total
9
9
 
10
- Tested against Ruby 2.6, 2.7, 3.0, 3.1 and 3.2, and against the latest patch releases of Rails 5.2, 6.0, 6.1 and 7.0.
10
+ Tested against Ruby 2.6, 2.7, 3.0, 3.1, 3.2 and 3.3 and against the latest patch releases of Rails 5.2, 6.0, 6.1, 7.0 and 7.1.
11
11
 
12
12
  Please note that -- unlike Rails' built-in counter-caches -- counter_culture does not currently change the behavior of the `.size` method on ActiveRecord associations. If you want to avoid a database query and read the cached value, please use the attribute name containing the counter cache directly.
13
13
 
@@ -74,7 +74,7 @@ class Group < ActiveRecord::Base
74
74
  has_many :members, through: :group_memberships, class: "User"
75
75
  end
76
76
 
77
- class Membership < ActiveRecord::Base
77
+ class GroupMembership < ActiveRecord::Base
78
78
  belongs_to :group
79
79
  belongs_to :member, class: "User"
80
80
  counter_culture :group, column_name: "members_count"
@@ -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.
@@ -358,6 +460,8 @@ Product.counter_culture_fix_counts touch: 'category_count_changed'
358
460
 
359
461
  The options start and finish are especially useful if you want multiple workers dealing with the same processing queue. You can make worker 1 handle all the records between id 1 and 9999 and worker 2 handle from 10000 and beyond by setting the :start and :finish option on each worker.
360
462
 
463
+ > **! NOTE**: the IDs we pass as `start` and `finish` here are in fact `Category` IDs, not `Product`!
464
+
361
465
  ```ruby
362
466
  Product.counter_culture_fix_counts start: 10_000
363
467
  # will fix counts for all counter caches defined on Product from record 10000 and onwards.
@@ -374,7 +478,7 @@ Product.counter_culture_fix_counts start: 2001, finish: 3000
374
478
 
375
479
  #### Fix counter cache using a replica database
376
480
 
377
- When fixing counter caches the number of reads usually vastly exceeds the number of writes. It can make sense to offload the road load to a replica database in this case. Rails 6 introduced [native handling of multiple database connections](https://guides.rubyonrails.org/v6.0/active_record_multiple_databases.html). You can use this to send read traffic to a read-only repliace using the option `db_connection_builder`:
481
+ When fixing counter caches the number of reads usually vastly exceeds the number of writes. It can make sense to offload the read load to a replica database in this case. Rails 6 introduced [native handling of multiple database connections](https://guides.rubyonrails.org/v6.0/active_record_multiple_databases.html). You can use this to send read traffic to a read-only replica using the option `db_connection_builder`:
378
482
 
379
483
  ```ruby
380
484
  Product.counter_culture_fix_counts db_connection_builder: proc{|reading, block|
@@ -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: "../"
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "rails", "~> 7.1.0"
6
+ gem "sqlite3", "~> 1.4"
7
+
8
+ gemspec path: "../"
@@ -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 ]
@@ -55,6 +57,8 @@ module CounterCulture
55
57
  else
56
58
  counter_delta_magnitude_for(obj)
57
59
  end
60
+ return if delta_magnitude.zero?
61
+
58
62
  # increment or decrement?
59
63
  operator = options[:increment] ? '+' : '-'
60
64
 
@@ -63,38 +67,51 @@ module CounterCulture
63
67
  # MySQL throws an ambiguous column error if any joins are present and we don't include the
64
68
  # table name. We isolate this change to MySQL because sqlite has the opposite behavior and
65
69
  # throws an exception if the table name is present after UPDATE.
66
- quoted_column = if klass.connection.adapter_name == 'Mysql2'
67
- "#{klass.quoted_table_name}.#{model.connection.quote_column_name(change_counter_column)}"
68
- else
69
- "#{model.connection.quote_column_name(change_counter_column)}"
70
- 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
71
77
 
72
78
  column_type = klass.type_for_attribute(change_counter_column).type
73
79
 
74
80
  # we don't use Rails' update_counters because we support changing the timestamp
75
81
  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}"
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)
79
86
  else
80
- updates << "#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{delta_magnitude}"
87
+ assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
81
88
  end
82
- # and here we update the timestamp, if so desired
89
+
90
+ # and this will update the timestamp, if so desired
83
91
  if touch
84
- current_time = obj.send(:current_time_from_proper_timezone)
85
- timestamp_columns = obj.send(:timestamp_attributes_for_update_in_model)
92
+ current_time = klass.send(:current_time_from_proper_timezone)
93
+ timestamp_columns = klass.send(:timestamp_attributes_for_update_in_model)
86
94
  if touch != true
87
95
  # starting in Rails 6 this is frozen
88
96
  timestamp_columns = timestamp_columns.dup
89
97
  timestamp_columns << touch
90
98
  end
91
99
  timestamp_columns.each do |timestamp_column|
92
- 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
+ )
93
106
  end
94
107
  end
95
108
 
96
109
  primary_key = relation_primary_key(relation, source: obj, was: options[:was])
97
110
 
111
+ if Thread.current[:aggregate_counter_updates]
112
+ Thread.current[:primary_key_map][klass] ||= primary_key
113
+ end
114
+
98
115
  if @with_papertrail
99
116
  instance = klass.where(primary_key => id_to_change).first
100
117
  if instance
@@ -118,8 +135,10 @@ module CounterCulture
118
135
  end
119
136
  end
120
137
 
121
- execute_now_or_after_commit(obj) do
122
- 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
123
142
  end
124
143
  end
125
144
  end
@@ -165,7 +184,7 @@ module CounterCulture
165
184
  def foreign_key_value(obj, relation, was = false)
166
185
  original_relation = relation
167
186
  relation = relation.is_a?(Enumerable) ? relation.dup : [relation]
168
-
187
+
169
188
  if was
170
189
  first = relation.shift
171
190
  foreign_key_value = attribute_was(obj, relation_foreign_key(first))
@@ -314,7 +333,9 @@ module CounterCulture
314
333
  changes_method = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? :saved_changes : :changed_attributes
315
334
  obj.public_send(changes_method).each do |key, value|
316
335
  old_value = ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0") ? value.first : value
317
- prev[key] = old_value
336
+ # We set old values straight to AR @attributes variable to avoid
337
+ # write_attribute callbacks from other gems (e.g. ArTransactionChanges)
338
+ prev.instance_variable_get(:@attributes).write_from_user(key, old_value)
318
339
  end
319
340
 
320
341
  prev
@@ -331,6 +352,7 @@ module CounterCulture
331
352
  end
332
353
 
333
354
  private
355
+
334
356
  def attribute_was(obj, attr)
335
357
  changes_method =
336
358
  if ACTIVE_RECORD_VERSION >= Gem::Version.new("5.1.0")
@@ -340,5 +362,63 @@ module CounterCulture
340
362
  end
341
363
  obj.public_send("#{attr}#{changes_method}")
342
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
343
423
  end
344
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
@@ -274,13 +276,13 @@ module CounterCulture
274
276
  # child in a Single Table Inheritance
275
277
  if reflect.active_record.column_names.include?('type') &&
276
278
  !model.descends_from_active_record?
277
- joins_sql += " AND #{target_table}.type IN ('#{model.name}')"
279
+ joins_sql += " AND #{target_table_alias}.type IN ('#{model.name}')"
278
280
  end
279
281
  if polymorphic?
280
282
  # adds 'type' condition to JOIN clause if the current model is a
281
283
  # polymorphic relation
282
284
  # NB only works for one-level relations
283
- joins_sql += " AND #{target_table}.#{reflect.foreign_type} = '#{relation_class.name}'"
285
+ joins_sql += " AND #{target_table_alias}.#{reflect.foreign_type} = '#{relation_class.name}'"
284
286
  end
285
287
  if index == reverse_relation.size - 1
286
288
  # conditions must be applied to the join on which we are counting
@@ -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.5.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.5.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: 2023-08-25 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
@@ -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
@@ -317,12 +317,15 @@ files:
317
317
  - gemfiles/rails_6.0.gemfile
318
318
  - gemfiles/rails_6.1.gemfile
319
319
  - gemfiles/rails_7.0.gemfile
320
+ - gemfiles/rails_7.1.gemfile
321
+ - gemfiles/rails_7.2.gemfile
320
322
  - lib/counter_culture.rb
321
323
  - lib/counter_culture/counter.rb
322
324
  - lib/counter_culture/extensions.rb
323
325
  - lib/counter_culture/reconciler.rb
324
326
  - lib/counter_culture/skip_updates.rb
325
327
  - lib/counter_culture/version.rb
328
+ - lib/counter_culture/with_connection.rb
326
329
  - lib/generators/counter_culture_generator.rb
327
330
  - lib/generators/templates/counter_culture_migration.rb.erb
328
331
  homepage: https://github.com/magnusvk/counter_culture
@@ -344,7 +347,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
344
347
  - !ruby/object:Gem::Version
345
348
  version: '0'
346
349
  requirements: []
347
- rubygems_version: 3.4.19
350
+ rubygems_version: 3.5.10
348
351
  signing_key:
349
352
  specification_version: 4
350
353
  summary: Turbo-charged counter caches for your Rails app.