counter_culture 3.12.2 → 3.13.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: f2241a3df22872b2a70afc2aa0ccb59c51e970723b77e2deeb268ea45f5c4554
4
- data.tar.gz: d67da1eaa6efed0026e482a120e8a187258ffbdf661b143823a7327781ccf2f2
3
+ metadata.gz: ce955a1cc71481d0223d0477b44823a9ed539e2209a77a0db5a5f90f09d87259
4
+ data.tar.gz: 68e564d152930f8fd663acbb99e64c0b5ed7ff451358b4f8439364d807b672c8
5
5
  SHA512:
6
- metadata.gz: 2b09f1e9ff6362a0096d5f80ecf6ba8ae58942ebe67eac8387566d98b7abf902555184f5ba017f543b1fe16ca44a58f07f87669b4190f039cf028d95cff02484
7
- data.tar.gz: 95b4f2da40553da3481bf7024155259b6268965a9a176738f90403c6aff341ecb079b7e9fe545db65843a44c77c99f879b595b4483c71e977ba733aa3a136400
6
+ metadata.gz: f77b8bece6b1242b3402c08cfece835f59d721ecef1f8330c66dc65a42a2ba6797c605f062e414a9d9a2f55d9113f6aa597176fd0e422221c16ee8f9a2648aaa
7
+ data.tar.gz: be715fdc0be1bfa8be1b555fcb28630f770012eee35459865520469a5211ab9f37e0894816cfcbd2d6f015d2704aa0b59eea44ed77a2c2cccdef230c373eac1e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 3.13.0 (April 9, 2026)
2
+
3
+ New features:
4
+ - Add `include_soft_deleted` option to keep soft-deleted records in the count (#428)
5
+
1
6
  ## 3.12.2 (February 25, 2026)
2
7
 
3
8
  Bugfixes:
data/README.md CHANGED
@@ -587,6 +587,25 @@ class SoftDelete < ActiveRecord::Base
587
587
  end
588
588
  ```
589
589
 
590
+ #### Counting soft-deleted records
591
+
592
+ By default, soft-deleted records are excluded from counter caches. If you want a
593
+ counter that includes soft-deleted records, use the `include_soft_deleted` option:
594
+
595
+ ```ruby
596
+ class SoftDelete < ActiveRecord::Base
597
+ acts_as_paranoid # or: include Discard::Model
598
+
599
+ belongs_to :company
600
+ counter_culture :company, column_name: 'all_records_count', include_soft_deleted: true
601
+ end
602
+ ```
603
+
604
+ With this option, soft-delete and restore operations will not change the counter.
605
+ Only hard-destroying a record (`really_destroy!` for Paranoia, `destroy` for
606
+ Discard) will decrement the counter. `counter_culture_fix_counts` will also
607
+ correctly include soft-deleted records when reconciling.
608
+
590
609
  ### PaperTrail integration
591
610
 
592
611
  If you are using the [`paper_trail` gem](https://github.com/airblade/paper_trail)
@@ -2,7 +2,7 @@ require_relative './with_connection'
2
2
 
3
3
  module CounterCulture
4
4
  class Counter
5
- CONFIG_OPTIONS = [ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch, :delta_magnitude, :execute_after_commit ]
5
+ CONFIG_OPTIONS = [ :column_names, :counter_cache_name, :delta_column, :foreign_key_values, :touch, :delta_magnitude, :execute_after_commit, :include_soft_deleted ]
6
6
  ACTIVE_RECORD_VERSION = Gem.loaded_specs["activerecord"].version
7
7
 
8
8
  attr_reader :model, :relation, *CONFIG_OPTIONS
@@ -19,6 +19,7 @@ module CounterCulture
19
19
  @delta_magnitude = options[:delta_magnitude] || 1
20
20
  @with_papertrail = options.fetch(:with_papertrail, false)
21
21
  @execute_after_commit = options.fetch(:execute_after_commit, false)
22
+ @include_soft_deleted = options.fetch(:include_soft_deleted, false)
22
23
 
23
24
  if @execute_after_commit
24
25
  begin
@@ -17,28 +17,21 @@ module CounterCulture
17
17
  unless @after_commit_counter_cache
18
18
  # initialize callbacks only once
19
19
  after_create :_update_counts_after_create
20
-
21
- before_destroy :_update_counts_after_destroy, unless: :destroyed_for_counter_culture?
20
+ before_destroy :_update_counts_before_destroy
21
+ after_update :_update_counts_after_update
22
22
 
23
23
  if respond_to?(:before_real_destroy) &&
24
24
  instance_methods.include?(:paranoia_destroyed?)
25
- before_real_destroy :_update_counts_after_destroy,
26
- if: -> (model) { !model.paranoia_destroyed? }
25
+ before_real_destroy :_update_counts_before_real_destroy
27
26
  end
28
27
 
29
- after_update :_update_counts_after_update, unless: :destroyed_for_counter_culture?
30
-
31
28
  if respond_to?(:before_restore)
32
- before_restore :_update_counts_after_create,
33
- if: -> (model) { model.deleted? }
29
+ before_restore :_update_counts_before_restore
34
30
  end
35
31
 
36
32
  if defined?(Discard::Model) && include?(Discard::Model)
37
- before_discard :_update_counts_after_destroy,
38
- if: ->(model) { !model.discarded? }
39
-
40
- before_undiscard :_update_counts_after_create,
41
- if: ->(model) { model.discarded? }
33
+ before_discard :_update_counts_before_discard
34
+ before_undiscard :_update_counts_before_undiscard
42
35
  end
43
36
 
44
37
  # we keep a list of all counter caches we must maintain
@@ -104,29 +97,77 @@ module CounterCulture
104
97
  end
105
98
 
106
99
  private
107
- # called by after_create callback
100
+
108
101
  def _update_counts_after_create
109
102
  self.class.after_commit_counter_cache.each do |counter|
110
- # increment counter cache
111
103
  counter.change_counter_cache(self, :increment => true)
112
104
  end
113
105
  end
114
106
 
115
- # called by after_destroy callback
116
- def _update_counts_after_destroy
117
- self.class.after_commit_counter_cache.each do |counter|
118
- unless destroyed?
119
- # decrement counter cache
120
- counter.change_counter_cache(self, :increment => false)
121
- end
107
+ # before_destroy: For Paranoia models this is a soft-delete; for all others
108
+ # (including Discard) it is a hard-destroy.
109
+ def _update_counts_before_destroy
110
+ # If destroy is called again on an already-destroyed record, don't double-decrement
111
+ return if destroyed?
112
+
113
+ if respond_to?(:paranoia_destroyed?)
114
+ # Paranoia: destroy = soft-delete
115
+ # If already soft-deleted, this is a redundant destroy call — don't decrement again
116
+ return if paranoia_destroyed?
117
+ _decrement_counters(skip_include_soft_deleted: true)
118
+ _handle_include_soft_deleted_column_transition
119
+ elsif destroyed_for_counter_culture?
120
+ # Discard: hard-destroy of already-discarded record
121
+ _decrement_counters(only_include_soft_deleted: true)
122
+ else
123
+ # Regular model or undiscarded Discard: hard-destroy
124
+ _decrement_counters
125
+ end
126
+ end
127
+
128
+ # before_real_destroy (Paranoia only)
129
+ def _update_counts_before_real_destroy
130
+ if paranoia_destroyed?
131
+ # Was already soft-deleted: only include_soft_deleted counters remain
132
+ _decrement_counters(only_include_soft_deleted: true)
133
+ else
134
+ # Not soft-deleted: decrement all counters
135
+ _decrement_counters
122
136
  end
123
137
  end
124
138
 
125
- # called by after_update callback
139
+ # before_restore (Paranoia only)
140
+ def _update_counts_before_restore
141
+ # Only increment if the record is currently soft-deleted (idempotency guard)
142
+ return unless deleted?
143
+ _increment_counters(skip_include_soft_deleted: true)
144
+ _handle_include_soft_deleted_column_transition
145
+ end
146
+
147
+ # before_discard (Discard only)
148
+ # Note: Discard's discard/undiscard use update_attribute which triggers
149
+ # after_update, so include_soft_deleted column transitions are handled
150
+ # there via the normal column_name change detection — no need to call
151
+ # _handle_include_soft_deleted_column_transition here.
152
+ def _update_counts_before_discard
153
+ # Only decrement if not already discarded (idempotency guard)
154
+ return if discarded?
155
+ _decrement_counters(skip_include_soft_deleted: true)
156
+ end
157
+
158
+ # before_undiscard (Discard only)
159
+ def _update_counts_before_undiscard
160
+ # Only increment if currently discarded (idempotency guard)
161
+ return unless discarded?
162
+ _increment_counters(skip_include_soft_deleted: true)
163
+ end
164
+
126
165
  def _update_counts_after_update
127
166
  self.class.after_commit_counter_cache.each do |counter|
128
- # figure out whether the applicable counter cache changed (this can happen
129
- # with dynamic column names)
167
+ # Don't update regular counters for soft-deleted records, but
168
+ # include_soft_deleted counters must still track association changes
169
+ next if destroyed_for_counter_culture? && !counter.include_soft_deleted
170
+
130
171
  counter_cache_name_was = counter.counter_cache_name_for(counter.previous_model(self))
131
172
  counter_cache_name = counter.counter_cache_name_for(self)
132
173
 
@@ -142,7 +183,53 @@ module CounterCulture
142
183
  end
143
184
  end
144
185
 
145
- # check if record is soft-deleted
186
+ def _decrement_counters(skip_include_soft_deleted: false, only_include_soft_deleted: false)
187
+ self.class.after_commit_counter_cache.each do |counter|
188
+ next if skip_include_soft_deleted && counter.include_soft_deleted
189
+ next if only_include_soft_deleted && !counter.include_soft_deleted
190
+ counter.change_counter_cache(self, :increment => false)
191
+ end
192
+ end
193
+
194
+ def _increment_counters(skip_include_soft_deleted: false)
195
+ self.class.after_commit_counter_cache.each do |counter|
196
+ next if skip_include_soft_deleted && counter.include_soft_deleted
197
+ counter.change_counter_cache(self, :increment => true)
198
+ end
199
+ end
200
+
201
+ # For include_soft_deleted counters with dynamic column_name Procs,
202
+ # evaluate the Proc on both the current state and the projected future
203
+ # state (after soft-delete/restore completes) to detect column transitions.
204
+ def _handle_include_soft_deleted_column_transition
205
+ future = _soft_delete_future_model
206
+ self.class.after_commit_counter_cache.each do |counter|
207
+ next unless counter.include_soft_deleted
208
+ column_was = counter.counter_cache_name_for(self)
209
+ column_now = counter.counter_cache_name_for(future)
210
+ next if column_was == column_now
211
+ counter.change_counter_cache(self, :increment => true, :counter_column => column_now) if column_now
212
+ counter.change_counter_cache(self, :increment => false, :counter_column => column_was) if column_was
213
+ end
214
+ end
215
+
216
+ # Creates a dup of the model with the soft-delete column toggled,
217
+ # so dynamic column_name Procs can be evaluated against the future state.
218
+ def _soft_delete_future_model
219
+ future = self.dup
220
+ if respond_to?(:paranoia_destroyed?)
221
+ col = 'deleted_at'
222
+ val = paranoia_destroyed? ? nil : Time.current
223
+ elsif defined?(Discard::Model) && self.class.include?(Discard::Model)
224
+ col = self.class.discard_column.to_s
225
+ val = discarded? ? nil : Time.current
226
+ else
227
+ return future
228
+ end
229
+ future.instance_variable_get(:@attributes).write_from_user(col, val)
230
+ future
231
+ end
232
+
146
233
  def destroyed_for_counter_culture?
147
234
  if respond_to?(:paranoia_destroyed?)
148
235
  paranoia_destroyed?
@@ -312,7 +312,9 @@ module CounterCulture
312
312
 
313
313
  # respect the deleted_at column if it exists
314
314
  if model.column_names.include?('deleted_at')
315
- joins_sql += " AND #{target_table_alias}.deleted_at IS NULL"
315
+ unless include_soft_deleted
316
+ joins_sql += " AND #{target_table_alias}.deleted_at IS NULL"
317
+ end
316
318
  end
317
319
 
318
320
  # respect the discard column if it exists
@@ -320,7 +322,9 @@ module CounterCulture
320
322
  model.include?(Discard::Model) &&
321
323
  model.column_names.include?(model.discard_column.to_s)
322
324
 
323
- joins_sql += " AND #{target_table_alias}.#{model.discard_column} IS NULL"
325
+ unless include_soft_deleted
326
+ joins_sql += " AND #{target_table_alias}.#{model.discard_column} IS NULL"
327
+ end
324
328
  end
325
329
  if index == reverse_relation.size - 1
326
330
  # conditions must be applied to the join on which we are counting
@@ -2,24 +2,20 @@ module CounterCulture
2
2
  module SkipUpdates
3
3
  private
4
4
 
5
- # called by after_create callback
6
- def _update_counts_after_create
7
- unless Array(Thread.current[:skip_counter_culture_updates]).include?(self.class)
8
- super
9
- end
10
- end
11
-
12
- # called by after_destroy callback
13
- def _update_counts_after_destroy
14
- unless Array(Thread.current[:skip_counter_culture_updates]).include?(self.class)
15
- super
16
- end
17
- end
18
-
19
- # called by after_update callback
20
- def _update_counts_after_update
21
- unless Array(Thread.current[:skip_counter_culture_updates]).include?(self.class)
22
- super
5
+ %i[
6
+ _update_counts_after_create
7
+ _update_counts_before_destroy
8
+ _update_counts_after_update
9
+ _update_counts_before_real_destroy
10
+ _update_counts_before_restore
11
+ _update_counts_before_discard
12
+ _update_counts_before_undiscard
13
+ _handle_include_soft_deleted_column_transition
14
+ ].each do |method_name|
15
+ define_method(method_name) do
16
+ unless Array(Thread.current[:skip_counter_culture_updates]).include?(self.class)
17
+ super()
18
+ end
23
19
  end
24
20
  end
25
21
  end
@@ -1,3 +1,3 @@
1
1
  module CounterCulture
2
- VERSION = '3.12.2'.freeze
2
+ VERSION = '3.13.0'.freeze
3
3
  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.12.2
4
+ version: 3.13.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: 2026-02-25 00:00:00.000000000 Z
11
+ date: 2026-04-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord