counter_culture 3.11.4 → 3.12.1

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: 4978dfaab9ec73c8b3215c2d2f811093cc00cdf7daccfe877749f301a09ae11a
4
- data.tar.gz: e2e1322882fdeab8c7239c7cf16e4019b5450d3f435c478ad1d9e2d33cf5a251
3
+ metadata.gz: 4d37272100e5c67ce47b96f522793380e863db895f97387ce02dcc609ae1cb24
4
+ data.tar.gz: 24580fa8abe60d089e5993e8a95c3c1740124ad058d5bdbba2c9766a83a635ba
5
5
  SHA512:
6
- metadata.gz: a60b46c8b713dbbb7f2969c9b6885e9975a1c5723eb3566a04b011380eafb077c3051386586cff7c77b5ba171888ef577aa5d1dd6be4a4d97ea8e1db1fdca91d
7
- data.tar.gz: 51feccb08cf927e8fc6f1d41f39f91269411db70d96d3b44d57ddf35c67130790c1340cb27aff0e0f4701934b40bc17485345667d1bffcbfb145eb4a5d98bea6
6
+ metadata.gz: 673ede183fe87f83356185732c651bf9a9348030b4daaee26ff63cb2774b09b08c0dadb720a5ac58d331586ae085bdbc558dba2000f6ef70e7f0ec781fdb6c46
7
+ data.tar.gz: 6df694a1661bc69582106cb429a557f4110cf7d2336534d33d7e64dce120e000489ec94a1c6982308593f9b1853ed0926cad63c6b09998293bf3139d01eaf93b
data/.circleci/config.yml CHANGED
@@ -13,23 +13,17 @@ jobs:
13
13
  database:
14
14
  type: string
15
15
  docker:
16
- - image: cimg/ruby:<< parameters.ruby-version >>
16
+ - image: ruby:<< parameters.ruby-version >>
17
17
  - image: cimg/postgres:14.1
18
18
  - image: cimg/mysql:8.0
19
19
  steps:
20
20
  - checkout
21
- - run:
22
- name: gem update --system --no-ri --no-rdoc
23
- # bundles sporadically fail with a weird Psych error if I don't do
24
- # this. It's annoying since it's slow but it's better than
25
- # non-deterministic build failures.
26
- command: sudo gem update --system
27
21
  - run:
28
22
  name: bundle update --no-ri --no-rdoc
29
23
  command: BUNDLE_GEMFILE=gemfiles/rails_<< parameters.rails-version >>.gemfile bundle update
30
24
  - run:
31
25
  name: install dockerize
32
- command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && sudo tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
26
+ command: wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz
33
27
  environment:
34
28
  DOCKERIZE_VERSION: v0.3.0
35
29
  - run:
@@ -48,124 +42,101 @@ workflows:
48
42
  - test:
49
43
  matrix:
50
44
  parameters:
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", "8.0"]
45
+ ruby-version: ["3.0", "3.1", "3.2", "3.3", "4.0"]
46
+ rails-version: ["6.0", "6.1", "7.0", "7.1", "7.2", "8.0", "8.1"]
53
47
  database: ["postgresql", "sqlite3", "mysql2"]
54
48
  exclude:
49
+ # Rails 7.2 requires Ruby 3.1+
55
50
  - ruby-version: "3.0"
56
- rails-version: "5.2"
51
+ rails-version: "7.2"
57
52
  database: "postgresql"
58
53
  - ruby-version: "3.0"
59
- rails-version: "5.2"
54
+ rails-version: "7.2"
55
+ database: "sqlite3"
56
+ - ruby-version: "3.0"
57
+ rails-version: "7.2"
60
58
  database: "mysql2"
59
+ # Rails 8.0 requires Ruby 3.2+
61
60
  - ruby-version: "3.0"
62
- rails-version: "5.2"
61
+ rails-version: "8.0"
62
+ database: "postgresql"
63
+ - ruby-version: "3.0"
64
+ rails-version: "8.0"
63
65
  database: "sqlite3"
66
+ - ruby-version: "3.0"
67
+ rails-version: "8.0"
68
+ database: "mysql2"
64
69
  - ruby-version: "3.1"
65
- rails-version: "5.2"
70
+ rails-version: "8.0"
66
71
  database: "postgresql"
67
72
  - ruby-version: "3.1"
68
- rails-version: "5.2"
69
- database: "mysql2"
70
- - ruby-version: "3.1"
71
- rails-version: "5.2"
73
+ rails-version: "8.0"
72
74
  database: "sqlite3"
73
- - ruby-version: "3.2"
74
- rails-version: "5.2"
75
+ - ruby-version: "3.1"
76
+ rails-version: "8.0"
77
+ database: "mysql2"
78
+ # Rails 8.1 requires Ruby 3.2+
79
+ - ruby-version: "3.0"
80
+ rails-version: "8.1"
75
81
  database: "postgresql"
76
- - ruby-version: "3.2"
77
- rails-version: "5.2"
82
+ - ruby-version: "3.0"
83
+ rails-version: "8.1"
84
+ database: "sqlite3"
85
+ - ruby-version: "3.0"
86
+ rails-version: "8.1"
78
87
  database: "mysql2"
79
- - ruby-version: "3.2"
80
- rails-version: "5.2"
88
+ - ruby-version: "3.1"
89
+ rails-version: "8.1"
90
+ database: "postgresql"
91
+ - ruby-version: "3.1"
92
+ rails-version: "8.1"
81
93
  database: "sqlite3"
82
- - ruby-version: "3.3"
83
- rails-version: "5.2"
94
+ - ruby-version: "3.1"
95
+ rails-version: "8.1"
96
+ database: "mysql2"
97
+ # Ruby 4.0 requires Rails 8.0+
98
+ - ruby-version: "4.0"
99
+ rails-version: "6.0"
84
100
  database: "postgresql"
85
- - ruby-version: "3.3"
86
- rails-version: "5.2"
101
+ - ruby-version: "4.0"
102
+ rails-version: "6.0"
103
+ database: "sqlite3"
104
+ - ruby-version: "4.0"
105
+ rails-version: "6.0"
87
106
  database: "mysql2"
88
- - ruby-version: "3.3"
89
- rails-version: "5.2"
107
+ - ruby-version: "4.0"
108
+ rails-version: "6.1"
109
+ database: "postgresql"
110
+ - ruby-version: "4.0"
111
+ rails-version: "6.1"
90
112
  database: "sqlite3"
91
- - ruby-version: "2.6"
113
+ - ruby-version: "4.0"
114
+ rails-version: "6.1"
115
+ database: "mysql2"
116
+ - ruby-version: "4.0"
92
117
  rails-version: "7.0"
93
118
  database: "postgresql"
94
- - ruby-version: "2.6"
119
+ - ruby-version: "4.0"
95
120
  rails-version: "7.0"
96
121
  database: "sqlite3"
97
- - ruby-version: "2.6"
122
+ - ruby-version: "4.0"
98
123
  rails-version: "7.0"
99
124
  database: "mysql2"
100
- - ruby-version: "2.6"
125
+ - ruby-version: "4.0"
101
126
  rails-version: "7.1"
102
127
  database: "postgresql"
103
- - ruby-version: "2.6"
128
+ - ruby-version: "4.0"
104
129
  rails-version: "7.1"
105
130
  database: "sqlite3"
106
- - ruby-version: "2.6"
131
+ - ruby-version: "4.0"
107
132
  rails-version: "7.1"
108
133
  database: "mysql2"
109
- - ruby-version: "2.6"
134
+ - ruby-version: "4.0"
110
135
  rails-version: "7.2"
111
136
  database: "postgresql"
112
- - ruby-version: "2.6"
137
+ - ruby-version: "4.0"
113
138
  rails-version: "7.2"
114
139
  database: "sqlite3"
115
- - ruby-version: "2.6"
140
+ - ruby-version: "4.0"
116
141
  rails-version: "7.2"
117
142
  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"
136
- - ruby-version: "2.6"
137
- rails-version: "8.0"
138
- database: "postgresql"
139
- - ruby-version: "2.6"
140
- rails-version: "8.0"
141
- database: "sqlite3"
142
- - ruby-version: "2.6"
143
- rails-version: "8.0"
144
- database: "mysql2"
145
- - ruby-version: "2.7"
146
- rails-version: "8.0"
147
- database: "postgresql"
148
- - ruby-version: "2.7"
149
- rails-version: "8.0"
150
- database: "sqlite3"
151
- - ruby-version: "2.7"
152
- rails-version: "8.0"
153
- database: "mysql2"
154
- - ruby-version: "3.0"
155
- rails-version: "8.0"
156
- database: "postgresql"
157
- - ruby-version: "3.0"
158
- rails-version: "8.0"
159
- database: "sqlite3"
160
- - ruby-version: "3.0"
161
- rails-version: "8.0"
162
- database: "mysql2"
163
- - ruby-version: "3.1"
164
- rails-version: "8.0"
165
- database: "postgresql"
166
- - ruby-version: "3.1"
167
- rails-version: "8.0"
168
- database: "sqlite3"
169
- - ruby-version: "3.1"
170
- rails-version: "8.0"
171
- database: "mysql2"
data/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 3.12.1 (January 6, 2026)
2
+
3
+ Bugfixes:
4
+ - Fix Rails 8.1+ compatibility by using Arel-based updates to avoid ambiguous column errors with UPDATE...FROM syntax (#425)
5
+
6
+ Changes:
7
+ - Dropped support for Ruby 2.6, 2.7 and Rails 5.2 (all EOL)
8
+ - Added support and testing for Ruby 4.0 and Rails 8.1 (#425)
9
+
1
10
  ## 3.11.4 (November 5, 2025)
2
11
 
3
12
  Bugfixes:
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, 3.2 and 3.3 and against the latest patch releases of Rails 5.2, 6.0, 6.1, 7.0, 7.1, 7.2 and 8.0.
10
+ Tested against Ruby 3.0, 3.1, 3.2, 3.3 and 4.0 and against the latest patch releases of Rails 6.0, 6.1, 7.0, 7.1, 7.2, 8.0 and 8.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
 
@@ -0,0 +1,8 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "~> 8.1.0"
6
+
7
+ gemspec path: "../"
8
+
@@ -59,32 +59,20 @@ module CounterCulture
59
59
  end
60
60
  return if delta_magnitude.zero?
61
61
 
62
- # increment or decrement?
63
- operator = options[:increment] ? '+' : '-'
64
-
65
62
  klass = relation_klass(relation, source: obj, was: options[:was])
66
63
 
67
- # MySQL throws an ambiguous column error if any joins are present and we don't include the
68
- # table name. We isolate this change to MySQL because sqlite has the opposite behavior and
69
- # throws an exception if the table name is present after UPDATE.
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
77
-
78
64
  column_type = klass.type_for_attribute(change_counter_column).type
79
65
 
80
- # we don't use Rails' update_counters because we support changing the timestamp
81
- updates = []
66
+ # Calculate signed delta for both paths
67
+ signed_delta = options[:increment] ? delta_magnitude : -delta_magnitude
82
68
 
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)
69
+ if Thread.current[:aggregate_counter_updates]
70
+ # Store structured data for later Arel assembly
71
+ remember_counter_update(klass, id_to_change, change_counter_column, signed_delta, column_type)
86
72
  else
87
- assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
73
+ # Build Arel expression for non-aggregate case
74
+ counter_expr = Counter.build_arel_counter_expr(klass, change_counter_column, signed_delta, column_type)
75
+ arel_updates = { change_counter_column => counter_expr }
88
76
  end
89
77
 
90
78
  # and this will update the timestamp, if so desired
@@ -97,12 +85,11 @@ module CounterCulture
97
85
  timestamp_columns << touch
98
86
  end
99
87
  timestamp_columns.each do |timestamp_column|
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
- )
88
+ if Thread.current[:aggregate_counter_updates]
89
+ remember_timestamp_update(klass, id_to_change, timestamp_column)
90
+ else
91
+ arel_updates[timestamp_column] = current_time
92
+ end
106
93
  end
107
94
  end
108
95
 
@@ -149,7 +136,9 @@ module CounterCulture
149
136
  unless Thread.current[:aggregate_counter_updates]
150
137
  execute_now_or_after_commit(obj) do
151
138
  conditions = primary_key_conditions(primary_key, id_to_change)
152
- klass.where(conditions).update_all updates.join(', ')
139
+ # Use Arel-based updates which let Rails handle column qualification properly,
140
+ # avoiding ambiguous column errors in Rails 8.1+ UPDATE...FROM syntax.
141
+ klass.where(conditions).distinct(false).update_all(arel_updates)
153
142
  # Determine if we should update the in-memory counter on the associated object.
154
143
  # When updating the old counter (was: true), we need to carefully consider two scenarios:
155
144
  # 1) The belongs_to relation changed (e.g., moving a child from parent A to parent B):
@@ -171,6 +160,7 @@ module CounterCulture
171
160
  end
172
161
 
173
162
  if should_update_counter
163
+ operator = options[:increment] ? '+' : '-'
174
164
  assign_to_associated_object(obj, relation, change_counter_column, operator, delta_magnitude)
175
165
  end
176
166
  end
@@ -454,68 +444,47 @@ module CounterCulture
454
444
  (association_object.public_send(change_counter_column) || 0).public_send(operator, delta_magnitude)
455
445
  end
456
446
 
457
- def assemble_money_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
458
- counter_update_snippet(
459
- "#{quoted_column} = COALESCE(CAST(#{quoted_column} as NUMERIC), 0)",
460
- klass,
461
- id_to_change,
462
- operator,
463
- delta_magnitude
464
- )
465
- end
466
-
467
- def assemble_counter_update(klass, id_to_change, quoted_column, operator, delta_magnitude)
468
- counter_update_snippet(
469
- "#{quoted_column} = COALESCE(#{quoted_column}, 0)",
470
- klass,
471
- id_to_change,
472
- operator,
473
- delta_magnitude
474
- )
475
- end
476
-
477
- def assemble_timestamp_update(klass, id_to_change, timestamp_column, value)
478
- update = "#{timestamp_column} ="
479
-
480
- if Thread.current[:aggregate_counter_updates]
481
- remember_timestamp_update(klass, id_to_change, update, value)
482
- else
483
- "#{update} '#{value.call}'"
484
- end
485
- end
486
-
487
447
  def primary_key_conditions(primary_key, fk_value)
488
448
  Array.wrap(primary_key)
489
449
  .zip(Array.wrap(fk_value))
490
450
  .to_h
491
451
  end
492
452
 
493
- def counter_update_snippet(update, klass, id_to_change, operator, delta_magnitude)
494
- if Thread.current[:aggregate_counter_updates]
495
- remember_counter_update(
496
- klass,
497
- id_to_change,
498
- "#{update} +",
499
- operator == '+' ? delta_magnitude : -delta_magnitude
453
+ # Builds an Arel expression for counter updates: COALESCE(col, 0) +/- delta
454
+ # This is a class method so it can be called from CounterCulture.aggregate_counter_updates
455
+ def self.build_arel_counter_expr(klass, column, delta, column_type)
456
+ arel_column = klass.arel_table[column]
457
+ arel_delta = Arel::Nodes.build_quoted(delta.abs)
458
+
459
+ coalesce = if column_type == :money
460
+ Arel::Nodes::NamedFunction.new(
461
+ 'COALESCE',
462
+ [Arel::Nodes::NamedFunction.new('CAST', [arel_column.as('NUMERIC')]), 0]
500
463
  )
501
464
  else
502
- "#{update} #{operator} #{delta_magnitude}"
465
+ Arel::Nodes::NamedFunction.new('COALESCE', [arel_column, 0])
466
+ end
467
+
468
+ if delta > 0
469
+ Arel::Nodes::Addition.new(coalesce, arel_delta)
470
+ else
471
+ Arel::Nodes::Subtraction.new(coalesce, arel_delta)
503
472
  end
504
473
  end
505
474
 
506
- def remember_counter_update(klass, id, operation, value)
475
+ def remember_counter_update(klass, id, column, delta, column_type)
507
476
  Thread.current[:aggregated_updates][klass] ||= {}
508
- Thread.current[:aggregated_updates][klass][id] ||= {}
509
- Thread.current[:aggregated_updates][klass][id][operation] ||= 0
477
+ Thread.current[:aggregated_updates][klass][id] ||= { counters: {}, timestamps: [] }
478
+ Thread.current[:aggregated_updates][klass][id][:counters][column] ||= { delta: 0, type: column_type }
510
479
 
511
- Thread.current[:aggregated_updates][klass][id][operation] += value
480
+ Thread.current[:aggregated_updates][klass][id][:counters][column][:delta] += delta
512
481
  end
513
482
 
514
- def remember_timestamp_update(klass, id, operation, value)
483
+ def remember_timestamp_update(klass, id, column)
515
484
  Thread.current[:aggregated_updates][klass] ||= {}
516
- Thread.current[:aggregated_updates][klass][id] ||= {}
485
+ Thread.current[:aggregated_updates][klass][id] ||= { counters: {}, timestamps: [] }
517
486
 
518
- Thread.current[:aggregated_updates][klass][id][operation] = value
487
+ Thread.current[:aggregated_updates][klass][id][:timestamps] << column
519
488
  end
520
489
  end
521
490
  end
@@ -174,7 +174,7 @@ module CounterCulture
174
174
 
175
175
  with_writing_db_connection do
176
176
  conditions = Array.wrap(relation_class.primary_key).map { |key| [key, record.send(key)] }.to_h
177
- relation_class.where(conditions).update_all(updates.join(', '))
177
+ relation_class.where(conditions).distinct(false).update_all(updates.join(', '))
178
178
  end
179
179
  end
180
180
  end
@@ -1,3 +1,3 @@
1
1
  module CounterCulture
2
- VERSION = '3.11.4'.freeze
2
+ VERSION = '3.12.1'.freeze
3
3
  end
@@ -26,15 +26,26 @@ module CounterCulture
26
26
 
27
27
  result = yield
28
28
 
29
- # aggregate the updates for each target record and execute SQL queries
29
+ # aggregate the updates for each target record and execute SQL queries using Arel
30
30
  Thread.current[:aggregated_updates].each do |klass, attrs|
31
31
  attrs.each do |rec_id, updates|
32
- update_snippets = updates.map do |operation, value|
33
- value = value.call if value.is_a?(Proc)
34
- %Q{#{operation} #{value.is_a?(String) ? "'#{value}'" : value}} unless value == 0
35
- end.compact
32
+ arel_updates = {}
36
33
 
37
- if update_snippets.any?
34
+ # Build counter updates
35
+ updates[:counters].each do |column, info|
36
+ next if info[:delta] == 0
37
+ arel_updates[column] = Counter.build_arel_counter_expr(klass, column, info[:delta], info[:type])
38
+ end
39
+
40
+ # Build timestamp updates (compute timestamp at execution time)
41
+ if updates[:timestamps].any?
42
+ current_time = klass.send(:current_time_from_proper_timezone)
43
+ updates[:timestamps].each do |column|
44
+ arel_updates[column] = current_time
45
+ end
46
+ end
47
+
48
+ if arel_updates.any?
38
49
  primary_key = Thread.current[:primary_key_map][klass]
39
50
 
40
51
  conditions =
@@ -42,7 +53,7 @@ module CounterCulture
42
53
  .zip(Array.wrap(rec_id))
43
54
  .to_h
44
55
 
45
- klass.where(conditions).update_all(update_snippets.join(', '))
56
+ klass.where(conditions).distinct(false).update_all(arel_updates)
46
57
  end
47
58
  end
48
59
  end
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.11.4
4
+ version: 3.12.1
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: 2025-11-05 00:00:00.000000000 Z
11
+ date: 2026-01-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -320,6 +320,7 @@ files:
320
320
  - gemfiles/rails_7.1.gemfile
321
321
  - gemfiles/rails_7.2.gemfile
322
322
  - gemfiles/rails_8.0.gemfile
323
+ - gemfiles/rails_8.1.gemfile
323
324
  - lib/counter_culture.rb
324
325
  - lib/counter_culture/configuration.rb
325
326
  - lib/counter_culture/counter.rb