bulk_destroyer 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 49ddeb360222e4f193bd0d67f64e23ed280f700349251326eb72f96983e5f2a0
4
+ data.tar.gz: 02ed589f50098a4029101b2b0550e474f9e31ef7bec9b65d0d87eedce23ee815
5
+ SHA512:
6
+ metadata.gz: b423cefd1a3cd77f72efee55ade3d8f75ff463087dc1d229cbac158cee2ad7c79359557263594bcbd3254ed358d21aac5c59bcf6d2f01aaa1622470f19b8717f
7
+ data.tar.gz: 1952408bf68dcf19ead6f2ca32626341feca96802c78b198039dfa00ab0c14cf6db9f8a93770dbe1b8fc3032866707d6f9ae7466e1ddfee57167e90d7b29bfb8
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 - 2026-10-22
4
+
5
+ - Initial release: `bulk_destroy_all` and `bulk_destroy!`.
6
+ - Fires all destroy callbacks (including children's) with set-based SQL; `after_destroy` runs after the bulk DELETE (Fiber mode).
7
+ - Supports `dependent:` :destroy, :delete_all, :nullify, restrict_*, has_many :through, HABTM, and `belongs_to` :destroy/:delete.
8
+ - `max_batch_records` / `oversized_tree` guard against the Fiber ceiling.
9
+ - Rails 7.1 - 8.2, any ActiveRecord adapter (tested on SQLite and PostgreSQL).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ramanan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # BulkDestroyer
2
+
3
+ `destroy_all` that fires **every destroy callback on every record** (including `dependent: :destroy`
4
+ descendants) but deletes with set-based SQL.
5
+
6
+ ```ruby
7
+ Post.where(archived: true).bulk_destroy_all # => { "Post" => 120, "Comment" => 940, ... }
8
+ Post.bulk_destroy_all(batch_size: 500)
9
+ post.comments.bulk_destroy_all
10
+ post.bulk_destroy!
11
+ ```
12
+
13
+ See [Benchmarks](#benchmarks) for timings. Query count is roughly one SELECT per association per level plus one DELETE
14
+ per 500 ids (`in_list_size`) per table, repeated for each batch of `batch_size` roots. It still grows with the
15
+ data, but about 700x more slowly than `destroy_all`'s two-plus queries per row (32,001 vs 45 queries at 1,000
16
+ posts, 160,001 vs 221 at 5,000).
17
+
18
+ ## How it works
19
+
20
+ Per batch of roots (default 1000, and never more than `max_batch_records` records; see below), inside one
21
+ transaction (`requires_new: true`):
22
+
23
+ 1. **Tree** - `ActiveRecord::Associations::Preloader` loads each `dependent: :destroy` association
24
+ once per level. Association scopes, `foreign_key`/`primary_key`, polymorphic `as:` and STI are
25
+ handled by Rails. A record reachable by several paths is placed at its deepest level.
26
+ 2. **Restrictions** - `restrict_with_exception` / `restrict_with_error` are checked first.
27
+ 3. **Start hooks** - each record's real `run_callbacks(:destroy)` runs inside a `Fiber` up to the
28
+ point where Rails would delete the row (before_destroy, first half of around_destroy), parents first.
29
+ 4. **Per level, deepest first** - `nullify` / `delete_all` / HABTM join rows, then one bulk `DELETE`
30
+ per table, then counter caches, then the records are marked destroyed and their fibers resume
31
+ (second half of around_destroy, `after_destroy`).
32
+
33
+ Because Rails runs the callback chain itself, `if:`/`unless:`, `prepend:`, callback objects, ordering
34
+ and `throw :abort` behave as in `destroy`. `after_destroy` really runs after the row is gone, and a
35
+ child's `after_destroy` runs while its parent's row still exists (same as `destroy`).
36
+
37
+ `after_commit` / `after_rollback` work: records are registered with the transaction exactly as
38
+ `destroy` registers them, so Active Storage purges etc. fire at commit, and a rolled-back batch
39
+ restores `destroyed?`.
40
+
41
+ ## Association support
42
+
43
+ | Association | How it is handled | Covered by a test |
44
+ |---|---|---|
45
+ | `has_many` / `has_one`, `dependent: :destroy` | children loaded once per level, destroyed with their hooks | yes |
46
+ | polymorphic `has_many ... as:`, `dependent: :destroy` | same, type column respected | yes (likes on posts and comments) |
47
+ | polymorphic `as:`, `dependent: :delete_all` / `:nullify` | one `DELETE` / `UPDATE` filtered by owner id **and** type; nullify clears the type column too | yes (two classes sharing an id) |
48
+ | `has_many :through` (plain `has_many` for the join, `dependent: :destroy` there) | join rows destroyed, far side untouched | yes |
49
+ | `has_many :through ..., dependent: :destroy` on the through association itself | join records destroyed with their hooks, far side untouched (same as Rails) | yes, compared with stock `destroy_all` |
50
+ | `has_and_belongs_to_many` | join rows deleted without callbacks (Rails does the same), from either side; far side untouched | yes |
51
+ | `belongs_to ..., dependent: :destroy` / `:delete`, polymorphic and `delegated_type` | target destroyed after its owners, see below | yes |
52
+ | `dependent: :restrict_with_exception` / `:restrict_with_error` | checked before any hook runs | exception form only |
53
+ | `dependent: :destroy_async`, nested `:through` with `dependent:` | raises `BulkDestroyer::UnsupportedAssociation` | `destroy_async` only |
54
+
55
+ The hash returned by `bulk_destroy_all` counts rows deleted per table, including `dependent: :delete_all` rows and
56
+ `dependent: :delete` targets. HABTM join rows and `nullify`d rows are not counted.
57
+
58
+ ## Differences from `destroy_all` (by design)
59
+
60
+ - All `before_destroy` hooks run before any row is deleted, rather than interleaved with child deletion.
61
+ - `throw :abort` / a failing hook rolls back the **whole batch** and raises
62
+ `ActiveRecord::RecordNotDestroyed` (or the hook's own error). There is no per-record skipping.
63
+ Batches are separate transactions; wrap the call in your own `transaction` for all-or-nothing.
64
+ A hook that raises `ActiveRecord::Rollback` is treated the same way: Rails' `transaction` would swallow it
65
+ and the batch would silently disappear, so the gem raises `ActiveRecord::RecordNotDestroyed` instead.
66
+ - `has_many :through, dependent: :destroy` and `has_and_belongs_to_many` remove join rows only (as Rails does).
67
+ - `belongs_to ..., dependent: :destroy` / `:delete` (this is what `delegated_type ..., dependent: :destroy` declares)
68
+ is supported. Rails destroys that target from the owner's `after_destroy`, so here each level of owners is
69
+ deleted first and then their targets are destroyed in a nested pass inside the same transaction, with their
70
+ own hooks, children and further `belongs_to` targets. Targets are preloaded (one query per target class,
71
+ polymorphic included), a target shared by several owners is destroyed once, and models that destroy each
72
+ other (`Slot belongs_to :holder, dependent: :destroy` / `Memo has_one :slot, dependent: :destroy`) terminate.
73
+ A target whose `before_destroy` aborts rolls back the whole batch, owners included.
74
+ - Counter caches are decremented with one grouped UPDATE per (column, amount) instead of one per row.
75
+ - `touch: true` is a callback and still runs per record.
76
+ - `destroy` itself is never called, so a model that overrides `destroy` (soft-delete gems) is bypassed.
77
+ Optimistic locking (`lock_version`) is not checked.
78
+
79
+ ## Not supported (raises `BulkDestroyer::UnsupportedAssociation`)
80
+
81
+ `dependent: :destroy_async` (on any association), nested `:through`, `:nullify` on `:through`,
82
+ and, for `delete_all`/`nullify`/`restrict`, composite foreign keys and instance-dependent scopes
83
+ (`-> (owner) { ... }`). (`dependent: :destroy` goes through Rails' preloader and does not have the
84
+ last two limits.)
85
+
86
+ ## Configuration
87
+
88
+ ```ruby
89
+ BulkDestroyer.batch_size = 1_000 # roots per transaction
90
+ BulkDestroyer.in_list_size = 500 # values per IN (...) chunk, capped per adapter
91
+ BulkDestroyer.hook_mode = :fiber # or :inline
92
+ BulkDestroyer.max_batch_records = 10_000 # records with destroy hooks per batch (see below)
93
+ BulkDestroyer.oversized_tree = :raise # or :inline, when ONE root's tree exceeds the cap
94
+ ```
95
+
96
+ `:fiber` needs `ActiveSupport::IsolatedExecutionState.isolation_level == :thread` (the default) because
97
+ hook fibers must share the caller's database connection; otherwise it raises `UnsupportedIsolation`.
98
+ `:inline` runs the whole callback chain before the DELETE (so `after_destroy` still sees the row).
99
+ Fiber-local `Thread.current[:x]` values are copied into the hook fibers.
100
+
101
+ ### The Fiber ceiling and `max_batch_records`
102
+
103
+ In `:fiber` mode every record in a batch's tree keeps one paused Fiber until its level has been deleted, so a
104
+ batch holds as many live Fibers as it has records with destroy hooks (the `batch_size` of 1000 counts roots, not
105
+ records). Ruby on Linux fails with `FiberError: can't set a guard page` at about 31,700 live Fibers with the
106
+ default `vm.max_map_count` of 65530 (two memory mappings per Fiber stack); measured on Ruby 3.4, trivial Fibers
107
+ cost about 12 KB each, real hook stacks cost more. A tree of 700 posts with 50 comments each (35,700 records)
108
+ used to fail this way and now works.
109
+
110
+ To stay clear of it, the batch size is also capped by records:
111
+
112
+ - If a batch's tree has more than `max_batch_records` records, **before any hook runs** the roots are split in
113
+ half and each half is run as its own batch (its own transaction), recursively. Everything still fires once;
114
+ you just get more, smaller batches. Splitting is not atomic across parts: wrap the call in your own
115
+ `transaction` if you need all-or-nothing.
116
+ - Targets of `belongs_to ..., dependent:` (the nested pass) are split the same way, within the same transaction.
117
+ - If a **single root's** tree alone exceeds the cap it cannot be split. By default
118
+ `BulkDestroyer::TreeTooLarge` is raised, before any hook runs and with nothing deleted. With
119
+ `oversized_tree = :inline` that batch runs its hooks without Fibers instead (its `after_destroy` hooks then run
120
+ before the DELETE).
121
+ - The cap is per batch. Nested `belongs_to` batches run while the outer batch's remaining Fibers are still
122
+ paused, so keep `max_batch_records` well under half of the ceiling. The default of 10,000 does.
123
+ - `:inline` mode creates no Fibers and ignores the cap.
124
+
125
+ A paused Fiber has no timeout in Ruby or Rails; what stays open meanwhile is the database transaction, so the
126
+ usual database and request timeouts apply to the whole call.
127
+
128
+ ## Testing
129
+
130
+ ```
131
+ BULK_DESTROYER_DB=sqlite3|postgresql|trilogy|mysql2 bundle exec ruby -Itest test/bulk_destroyer_test.rb
132
+ BULK_DESTROYER_HOOK_MODE=inline ... # run the suite in inline hook mode
133
+ ```
134
+
135
+ ## Benchmarks
136
+
137
+ `rake bench` seeds a large tree with `insert_all` (no callbacks; about 17 rows per post: comments, polymorphic likes,
138
+ taggings, a HABTM join row, `delete_all` and `nullify` children, a `has_one`, counter caches) and then runs three
139
+ strategies against identical, freshly seeded data:
140
+
141
+ - `Post.destroy_all` (stock)
142
+ - `Post.find_each(&:destroy)` (per-record loop)
143
+ - `Post.bulk_destroy_all`
144
+
145
+ It first asserts that all three leave the same rows behind and fire exactly the same hook calls, then asserts that
146
+ `bulk_destroy_all` issues at least 10x fewer queries and is faster. Size and database are configurable:
147
+ `BENCH_POSTS=1000 BENCH_COMMENTS=5 BULK_DESTROYER_DB=postgresql rake bench`. It is skipped by `rake test`. The same file also has a test that destroys the 35,700-record tree described
148
+ under [The Fiber ceiling](#the-fiber-ceiling-and-max_batch_records) with default settings.
149
+
150
+ Single runs on a small cloud VM, Rails 8.1.3.1, Ruby 3.4.8, database on the same machine:
151
+
152
+ | Database | Posts / rows | Strategy | Seconds | Queries | Hook calls | Speedup |
153
+ |---|---|---|---|---|---|---|
154
+ | SQLite (in-memory) | 1,000 / 17,003 | `destroy_all` | 9.44 | 32,001 | 84,000 | 1.0x |
155
+ | | | `find_each(&:destroy)` | 9.25 | 32,002 | 84,000 | 1.0x |
156
+ | | | `bulk_destroy_all` | 1.34 | 45 | 84,000 | 7.1x |
157
+ | SQLite (in-memory) | 5,000 / 85,003 | `destroy_all` | 58.33 | 160,001 | 420,000 | 1.0x |
158
+ | | | `find_each(&:destroy)` | 56.76 | 160,006 | 420,000 | 1.0x |
159
+ | | | `bulk_destroy_all` | 6.69 | 221 | 420,000 | 8.7x |
160
+ | PostgreSQL 16 (TCP) | 1,000 / 17,003 | `destroy_all` | 19.73 | 32,001 | 84,000 | 1.0x |
161
+ | | | `find_each(&:destroy)` | 20.57 | 32,002 | 84,000 | 1.0x |
162
+ | | | `bulk_destroy_all` | 1.58 | 45 | 84,000 | 12.5x |
163
+
164
+ What the numbers say: the speedup comes from removing queries, and the callbacks themselves still cost the same
165
+ (84,000 hook calls either way), so the gap is smallest on an in-memory database and larger when each query costs
166
+ a round trip. I only measured a database on the same machine; I would expect a remote database to widen the gap
167
+ but have not measured it. `destroy_all` and `find_each(&:destroy)` are equivalent in cost, since both issue the
168
+ same per-record queries; the loop only avoids loading every record at once.
169
+
170
+ ## Compatibility
171
+
172
+ Supports Rails **7.1 through 8.2** (`activerecord >= 7.1, < 8.3`). The full suite (44 tests, foreign keys
173
+ enforced) passes unchanged on:
174
+
175
+ | Rails | SQLite 3 | PostgreSQL 16 | MariaDB 10.11 |
176
+ |---|---|---|---|
177
+ | 7.1.6 | pass | not run | not run |
178
+ | 7.2.4 | pass | not run | not run |
179
+ | 8.0.5 | pass | not run | not run |
180
+ | 8.1.3.1 | pass | pass | not run |
181
+ | 8.2.0.alpha (main) | pass | pass (first 22 tests; `belongs_to` tests not run) | pass (first 22 tests; `belongs_to` tests not run) |
182
+
183
+ Ruby was only tested on 3.4; the `>= 3.1` floor in the gemspec is an assumption (Rails itself enforces
184
+ its own Ruby floor). Oracle and SQL Server are unverified (IN-list ceilings are built in, nothing has been run there).
185
+
186
+ **Rails 7.0 and earlier are not supported.** In 7.0 the connection lock is held by the calling fiber for the
187
+ whole transaction, so any hook that runs a query inside a hook Fiber raises
188
+ `ThreadError: deadlock; lock already owned by another fiber`. Rails 7.1 changed that locking. On 7.0,
189
+ `BulkDestroyer.hook_mode = :inline` avoids Fibers (in an earlier 22-test run, 20 passed; the other two assert the
190
+ post-DELETE `after_destroy` timing that inline mode intentionally gives up), but this is not a supported configuration.
191
+
192
+ Version differences handled internally: `Model.with_connection` (7.2+, falls back to `connection` on 7.1) and
193
+ `counter_cached_association_names` (7.2+, falls back to the `belongs_to` reflections on 7.1).
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ module RelationMethods
5
+ # Like #destroy_all: every destroy callback fires on every record, including dependent:
6
+ # :destroy descendants. Unlike #destroy_all: one SELECT per association per level and one
7
+ # DELETE per table per level.
8
+ def bulk_destroy_all(batch_size: BulkDestroyer.batch_size)
9
+ Destroyer.new(self, batch_size: batch_size).call
10
+ end
11
+ end
12
+
13
+ module ClassMethods
14
+ def bulk_destroy_all(batch_size: BulkDestroyer.batch_size)
15
+ all.bulk_destroy_all(batch_size: batch_size)
16
+ end
17
+ end
18
+
19
+ module RecordMethods
20
+ def bulk_destroy!
21
+ Destroyer.new(self).call
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ # One transaction's worth of work: a batch of root records and everything below them.
5
+ #
6
+ # 1. Tree load the dependent: :destroy graph (one query per association per level)
7
+ # 2. restrictions dependent: :restrict_with_* checked before anything runs
8
+ # 3. start hooks before_destroy / first half of around_destroy, parents before children
9
+ # 4. per level, deepest first:
10
+ # nullify / delete_all / HABTM join rows -> bulk DELETE -> counter caches
11
+ # -> mark records destroyed -> after_destroy / second half of around_destroy
12
+ #
13
+ # Level-by-level means a child's after_destroy runs while its parent row still exists, and a
14
+ # parent's after_destroy runs after its children are gone, as with plain `destroy`.
15
+ class Batch
16
+ def initialize(roots, handled: Set.new)
17
+ @roots = roots
18
+ @handled = handled
19
+ end
20
+
21
+ def run
22
+ levels = Tree.new(@roots, exclude: @handled).build
23
+ records = levels.flatten
24
+ inline = oversized?(records) # raises before anything ran if the tree is too big for Fibers
25
+ records.each { |record| @handled << Tree.key_for(record) }
26
+ counts = Hash.new(0)
27
+ runner = nil
28
+
29
+ begin
30
+ Dependents.check_restrictions!(records)
31
+ runner = HookRunner.build(mode: inline ? :inline : BulkDestroyer.hook_mode)
32
+ records.each { |record| prepare(record) }
33
+ levels.flatten.each do |record|
34
+ next if runner.start(record)
35
+
36
+ raise ActiveRecord::RecordNotDestroyed.new("Failed to destroy #{record.class.name} with #{record.class.primary_key}=#{record.id}", record)
37
+ end
38
+
39
+ levels.reverse_each do |level|
40
+ Dependents.apply_bulk_operations(level, counts)
41
+ delete_rows(level, counts)
42
+ CounterCaches.decrement(level)
43
+ level.each { |record| mark_destroyed(record) }
44
+ level.each { |record| runner.finish(record) }
45
+ destroy_belongs_to_targets(level, counts)
46
+ end
47
+ ensure
48
+ runner&.abort_all
49
+ records.each { |record| record.remove_instance_variable(CascadeGuard::FLAG) if record.instance_variable_defined?(CascadeGuard::FLAG) }
50
+ end
51
+
52
+ counts
53
+ end
54
+
55
+ private
56
+
57
+ # belongs_to ..., dependent: :destroy / :delete. Rails does this in the owner's after_destroy; here it
58
+ # happens once the whole level of owners is gone. :destroy targets go through a nested Batch (their own
59
+ # hooks, children, restrictions and further belongs_to targets), still inside this transaction.
60
+ def destroy_belongs_to_targets(level, counts)
61
+ found = Dependents.belongs_to_targets(level, @handled)
62
+
63
+ run_nested(found[:destroy], counts) unless found[:destroy].empty?
64
+
65
+ found[:delete].group_by { |target| target.class.base_class }.each do |base, group|
66
+ ids = sortable(group.map(&:id))
67
+ Dependents.in_slices(ids, base) do |slice|
68
+ deleted = base.unscoped.where(base.primary_key => slice).delete_all
69
+ counts[base.name] += deleted if deleted.positive?
70
+ end
71
+ # Same end state as Rails' #delete: marked destroyed, no callbacks, no commit hooks.
72
+ group.each do |target|
73
+ target.instance_variable_set(:@destroyed, true)
74
+ target.freeze
75
+ end
76
+ end
77
+ end
78
+
79
+ # Same transaction as the enclosing batch. A tree that is too big for Fibers is split by targets.
80
+ def run_nested(targets, counts)
81
+ Batch.new(targets, handled: @handled).run.each { |name, n| counts[name] += n }
82
+ rescue SplitRequired
83
+ middle = targets.size / 2
84
+ run_nested(targets.first(middle), counts)
85
+ run_nested(targets.drop(middle), counts)
86
+ end
87
+
88
+ # One paused Fiber per record, all alive at once, so the tree size is capped. => true when this batch
89
+ # has to run its hooks without Fibers.
90
+ def oversized?(records)
91
+ return false if BulkDestroyer.hook_mode == :inline
92
+ return false if records.size <= BulkDestroyer.max_batch_records
93
+ raise SplitRequired if @roots.size > 1
94
+
95
+ case BulkDestroyer.oversized_tree
96
+ when :inline then true
97
+ when :raise
98
+ raise TreeTooLarge,
99
+ "the dependent: :destroy tree below #{@roots.first.class.name}##{@roots.first.id} has " \
100
+ "#{records.size} records with destroy hooks, more than BulkDestroyer.max_batch_records " \
101
+ "(#{BulkDestroyer.max_batch_records}). Each needs a paused Fiber and Ruby on Linux fails around " \
102
+ "31,000. Raise max_batch_records (keep it well below that), or set " \
103
+ "BulkDestroyer.oversized_tree = :inline to run these hooks without Fibers " \
104
+ "(after_destroy then runs before the DELETE)."
105
+ else raise ArgumentError, "unknown oversized_tree #{BulkDestroyer.oversized_tree.inspect}"
106
+ end
107
+ end
108
+
109
+ def prepare(record)
110
+ record.instance_variable_set(CascadeGuard::FLAG, true)
111
+ record.send(:_raise_readonly_record_error) if record.readonly?
112
+ # Same bookkeeping Rails' with_transaction_returning_status does for a normal destroy: it
113
+ # lets after_commit / after_rollback fire and restores @destroyed if the batch rolls back.
114
+ record.send(:add_to_transaction)
115
+ record.send(:remember_transaction_record_state)
116
+ end
117
+
118
+ def delete_rows(level, counts)
119
+ level.group_by { |record| record.class.base_class }.each do |base, group|
120
+ ids = sortable(group.map(&:id))
121
+ base.transaction do
122
+ Dependents.in_slices(ids, base) do |slice|
123
+ counts[base.name] += base.unscoped.where(base.primary_key => slice).delete_all
124
+ end
125
+ end
126
+ end
127
+ end
128
+
129
+ # Ascending ids give concurrent bulk deletes a consistent lock order (fewer deadlocks).
130
+ def sortable(ids)
131
+ ids.sort
132
+ rescue ArgumentError, NoMethodError
133
+ ids
134
+ end
135
+
136
+ def mark_destroyed(record)
137
+ record.instance_variable_set(:@_trigger_destroy_callback, true)
138
+ record.instance_variable_set(:@destroyed, true)
139
+ record.instance_variable_set(:@previously_new_record, false)
140
+ record.freeze
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ # Rails implements `dependent:` as a before_destroy/after_destroy lambda that calls
5
+ # association.handle_dependency, which loads and destroys children one by one. While a record
6
+ # is being handled by BulkDestroyer we handle every dependent: mode ourselves, so the owner's
7
+ # own callback must do nothing. Records not flagged behave exactly as in stock Rails.
8
+ module CascadeGuard
9
+ FLAG = :@__bulk_destroying
10
+
11
+ def handle_dependency
12
+ return if owner.instance_variable_get(FLAG)
13
+
14
+ super
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ # Rails decrements belongs_to counter caches inside Persistence#destroy_row, which bulk
5
+ # deletion bypasses. This reproduces it with one UPDATE per (counter column, amount) rather
6
+ # than one per row.
7
+ module CounterCaches
8
+ module_function
9
+
10
+ # Rails 7.2+ exposes counter_cached_association_names; 7.1 only has the reflections.
11
+ def counter_cached_reflections(klass)
12
+ if klass.respond_to?(:counter_cached_association_names)
13
+ klass.counter_cached_association_names.map { |name| klass._reflect_on_association(name) }
14
+ else
15
+ klass.reflect_on_all_associations(:belongs_to).select(&:counter_cache_column)
16
+ end
17
+ end
18
+
19
+ def decrement(records)
20
+ records.group_by(&:class).each do |klass, group|
21
+ counter_cached_reflections(klass).each do |reflection|
22
+ name = reflection.name
23
+ simple = !reflection.polymorphic? && Array(reflection.foreign_key).size == 1
24
+ tally = Hash.new(0)
25
+
26
+ group.each do |record|
27
+ # Same rule as Rails: a child destroyed via its parent's dependent: association does
28
+ # not touch that parent's counter.
29
+ via = record.destroyed_by_association
30
+ next if via && via.foreign_key == reflection.foreign_key
31
+
32
+ if simple
33
+ id = record._read_attribute(reflection.foreign_key)
34
+ tally[id] += 1 if id
35
+ else
36
+ record.association(name).decrement_counters
37
+ end
38
+ end
39
+
40
+ tally.group_by { |_id, n| n }.each do |n, pairs|
41
+ counters = { reflection.counter_cache_column => -n }
42
+ counters[:touch] = reflection.options[:touch] if reflection.options[:touch]
43
+ Dependents.in_slices(pairs.map(&:first), reflection.klass) do |ids|
44
+ reflection.klass.update_counters(ids.sort, counters.dup)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ # Understands the `dependent:` options of ActiveRecord associations and reproduces their
5
+ # semantics with set-based SQL.
6
+ module Dependents
7
+ Plan = Struct.new(:reflection, :kind)
8
+
9
+ module_function
10
+
11
+ # kinds: :destroy (children join the tree), :through_destroy (join records join the tree),
12
+ # :delete_all, :nullify, :restrict, :habtm (join rows are deleted without callbacks)
13
+ def plans_for(klass)
14
+ klass.reflect_on_all_associations.filter_map { |reflection| classify(reflection) }
15
+ end
16
+
17
+ def classify(reflection)
18
+ return Plan.new(reflection, :habtm) if reflection.macro == :has_and_belongs_to_many
19
+
20
+ dependent = reflection.options[:dependent]
21
+ return nil if dependent.nil?
22
+
23
+ if reflection.belongs_to?
24
+ # Includes delegated_type, which declares `belongs_to ..., polymorphic: true, dependent: ...`.
25
+ case dependent
26
+ when :destroy then return Plan.new(reflection, :belongs_to_destroy)
27
+ when :delete then return Plan.new(reflection, :belongs_to_delete)
28
+ else unsupported!(reflection, "belongs_to with dependent: #{dependent.inspect} is not supported")
29
+ end
30
+ end
31
+
32
+ through = reflection.options[:through]
33
+ unsupported!(reflection, "nested has_many :through is not supported") if through && reflection.nested?
34
+
35
+ case dependent
36
+ when :destroy
37
+ Plan.new(reflection, through ? :through_destroy : :destroy)
38
+ when :delete_all, :delete
39
+ Plan.new(reflection, :delete_all)
40
+ when :nullify
41
+ unsupported!(reflection, "dependent: :nullify on a :through association") if through
42
+ Plan.new(reflection, :nullify)
43
+ when :restrict_with_exception, :restrict_with_error
44
+ Plan.new(reflection, :restrict)
45
+ when :destroy_async
46
+ unsupported!(reflection, "dependent: :destroy_async cannot be combined with bulk destroy")
47
+ else
48
+ unsupported!(reflection, "unknown dependent: #{dependent.inspect}")
49
+ end
50
+ end
51
+
52
+ def unsupported!(reflection, message)
53
+ raise UnsupportedAssociation, "#{reflection.active_record.name}##{reflection.name}: #{message}"
54
+ end
55
+
56
+ # Checks every restrict_with_* association before any hook runs.
57
+ def check_restrictions!(records)
58
+ records.group_by(&:class).each do |klass, owners|
59
+ plans_for(klass).each do |plan|
60
+ next unless plan.kind == :restrict
61
+
62
+ reflection = plan.reflection
63
+ source = reflection.options[:through] ? reflection.through_reflection : reflection
64
+ owner_keys = owners.to_h { |o| [o._read_attribute(source.active_record_primary_key), o] }
65
+ blocked = Set.new
66
+ in_slices(owner_keys.keys, source.klass) do |keys|
67
+ blocked.merge(child_relation(source, keys, owners.first).distinct.pluck(source.foreign_key))
68
+ end
69
+ next if blocked.empty?
70
+
71
+ if reflection.options[:dependent] == :restrict_with_exception
72
+ raise ActiveRecord::DeleteRestrictionError.new(reflection.name)
73
+ end
74
+
75
+ owner = owner_keys.fetch(blocked.first)
76
+ human = owner.class.human_attribute_name(reflection.name).downcase
77
+ owner.errors.add(:base, :"restrict_dependent_destroy.#{reflection.has_one? ? 'has_one' : 'has_many'}", record: human)
78
+ raise ActiveRecord::RecordNotDestroyed.new("Failed to destroy #{owner.class.name}: #{owner.errors.full_messages.to_sentence}", owner)
79
+ end
80
+ end
81
+ end
82
+
83
+ # Targets of `belongs_to ..., dependent: :destroy / :delete` for one level of owners.
84
+ # Rails destroys these inside the owner's after_destroy, i.e. once the owner's row is gone, so
85
+ # Batch calls this after each level. Preloader loads them in one query per target class,
86
+ # polymorphic included.
87
+ #
88
+ # +handled+ holds the keys of records already in (or already taken by) this batch; those are
89
+ # skipped, and every target returned is added to it. That makes a target shared by several owners
90
+ # come back once, and stops two models that point at each other from destroying each other forever.
91
+ #
92
+ # => { destroy: [records], delete: [records] }
93
+ def belongs_to_targets(owners, handled)
94
+ found = { destroy: [], delete: [] }
95
+ owners.group_by(&:class).each do |klass, group|
96
+ plans_for(klass).each do |plan|
97
+ next unless plan.kind == :belongs_to_destroy || plan.kind == :belongs_to_delete
98
+
99
+ name = plan.reflection.name
100
+ ActiveRecord::Associations::Preloader.new(records: group, associations: [name]).call
101
+ bucket = plan.kind == :belongs_to_destroy ? found[:destroy] : found[:delete]
102
+ group.each do |owner|
103
+ target = owner.association(name).target
104
+ next if target.nil? || target.destroyed?
105
+ next unless handled.add?(Tree.key_for(target))
106
+
107
+ bucket << target
108
+ end
109
+ end
110
+ end
111
+ found
112
+ end
113
+
114
+ # :delete_all, :nullify and HABTM join rows for one level of the tree.
115
+ # Rows removed by dependent: :delete_all are added to +counts+ under their model's base class name
116
+ # (HABTM join rows are not counted: the join table has no model of its own).
117
+ def apply_bulk_operations(records, counts)
118
+ records.group_by(&:class).each do |klass, owners|
119
+ plans_for(klass).each do |plan|
120
+ reflection = plan.reflection
121
+ case plan.kind
122
+ when :delete_all
123
+ source = reflection.options[:through] ? reflection.through_reflection : reflection
124
+ each_child_relation(source, owners) do |relation|
125
+ deleted = relation.delete_all
126
+ counts[source.klass.base_class.name] += deleted if deleted.positive?
127
+ end
128
+ when :nullify
129
+ attributes = { reflection.foreign_key => nil }
130
+ attributes[reflection.type] = nil if reflection.type
131
+ each_child_relation(reflection, owners) { |relation| relation.update_all(attributes) }
132
+ when :habtm
133
+ middle = klass._reflections.values.find { |r| r.parent_reflection.equal?(reflection) && r.options[:through].nil? }
134
+ each_child_relation(middle, owners) { |relation| relation.delete_all }
135
+ end
136
+ end
137
+ end
138
+ end
139
+
140
+ def each_child_relation(reflection, owners)
141
+ keys = owners.map { |o| o._read_attribute(reflection.active_record_primary_key) }
142
+ in_slices(keys, reflection.klass) do |slice|
143
+ yield child_relation(reflection, slice, owners.first)
144
+ end
145
+ end
146
+
147
+ # Rows that belong to +owner_keys+ through +reflection+, honouring the association scope,
148
+ # custom keys, and polymorphic `as:` type columns.
149
+ def child_relation(reflection, owner_keys, sample_owner)
150
+ if Array(reflection.foreign_key).size > 1
151
+ unsupported!(reflection, "composite foreign keys are only supported for dependent: :destroy")
152
+ end
153
+
154
+ relation = reflection.klass.all
155
+ if reflection.scope
156
+ if reflection.scope.arity.positive?
157
+ unsupported!(reflection, "instance-dependent association scopes are only supported for dependent: :destroy")
158
+ end
159
+ relation = relation.instance_exec(&reflection.scope)
160
+ end
161
+ relation = relation.where(reflection.foreign_key => owner_keys)
162
+ relation = relation.where(reflection.type => sample_owner.class.polymorphic_name) if reflection.type
163
+ relation
164
+ end
165
+
166
+ # Splits long IN lists to stay below adapter limits (Oracle 1000, SQL Server ~2100, ...).
167
+ def in_slices(values, model, &block)
168
+ limit = BulkDestroyer.in_list_size_for(model)
169
+ values.each_slice(limit, &block)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ class Destroyer
5
+ def initialize(source, batch_size: BulkDestroyer.batch_size)
6
+ @source = source
7
+ @batch_size = Integer(batch_size)
8
+ raise ArgumentError, "batch_size must be positive" unless @batch_size.positive?
9
+ end
10
+
11
+ # => { "Post" => 3, "Comment" => 12, ... } rows deleted per table (by base class name)
12
+ def call
13
+ counts = Hash.new(0)
14
+ each_root_batch { |roots| run_roots(roots, counts) }
15
+ counts.to_h
16
+ end
17
+
18
+ private
19
+
20
+ def run_roots(roots, counts)
21
+ # requires_new: a failing batch rolls back to its own savepoint (inside a caller's
22
+ # transaction) or its own real transaction (outside one).
23
+ completed = false
24
+ roots.first.class.transaction(requires_new: true) do
25
+ Batch.new(roots).run.each { |name, n| counts[name] += n }
26
+ completed = true
27
+ end
28
+
29
+ # `transaction` swallows ActiveRecord::Rollback: it rolls the batch back and returns nil without
30
+ # raising. A hook that does that would otherwise make this batch vanish silently.
31
+ return if completed
32
+
33
+ raise ActiveRecord::RecordNotDestroyed.new(
34
+ "bulk destroy rolled back a batch of #{roots.size} #{roots.first.class.name} record(s): " \
35
+ "ActiveRecord::Rollback was raised in a destroy callback",
36
+ roots.first
37
+ )
38
+ rescue SplitRequired
39
+ # The tree under these roots has too many records for one batch of paused Fibers. Nothing ran yet
40
+ # (the savepoint is already rolled back); do each half as its own batch.
41
+ middle = roots.size / 2
42
+ run_roots(roots.first(middle), counts)
43
+ run_roots(roots.drop(middle), counts)
44
+ end
45
+
46
+ def each_root_batch(&block)
47
+ case @source
48
+ when ActiveRecord::Base
49
+ yield [@source] if @source.persisted?
50
+ when ActiveRecord::Relation
51
+ @source.find_in_batches(batch_size: @batch_size, &block)
52
+ else
53
+ raise ArgumentError, "expected an ActiveRecord relation or record, got #{@source.class}"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ class Error < StandardError; end
5
+
6
+ # An association configuration the gem cannot reproduce faithfully
7
+ # (e.g. belongs_to dependent:, :destroy_async, composite-key :nullify).
8
+ class UnsupportedAssociation < Error; end
9
+
10
+ # ActiveSupport::IsolatedExecutionState.isolation_level == :fiber and hook_mode == :fiber.
11
+ class UnsupportedIsolation < Error; end
12
+
13
+ # The dependent: :destroy graph contains a cycle in the data.
14
+ class CycleDetected < Error; end
15
+
16
+ # One root's dependent: :destroy tree has more hook-bearing records than BulkDestroyer.max_batch_records
17
+ # and cannot be split any further (see BulkDestroyer.oversized_tree).
18
+ class TreeTooLarge < Error; end
19
+
20
+ # Internal: a batch with several roots built a tree over the limit before running any hook; the caller
21
+ # retries with the roots split in two. Never reaches application code.
22
+ class SplitRequired < Error; end
23
+
24
+ # Raised inside suspended hook fibers to unwind them (so `ensure` blocks in around_destroy
25
+ # callbacks run). Deliberately not a StandardError so `rescue => e` in app hooks can't swallow it.
26
+ class Unwind < Exception; end # rubocop:disable Lint/InheritException
27
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ # Runs a record's real :destroy callback chain in two halves around the bulk DELETE.
5
+ #
6
+ # FiberRunner: each record's `run_callbacks(:destroy)` runs inside a Fiber whose block yields.
7
+ # - start(record) runs before_destroy and the first half of around_destroy, up to the block
8
+ # - finish(record) resumes it after the rows are gone: second half of around_destroy, then
9
+ # after_destroy. Conditions (if:/unless:), ordering, prepend:, callback
10
+ # objects and `throw :abort` are all handled by Rails itself.
11
+ #
12
+ # InlineRunner: runs the whole chain before the DELETE (after_destroy sees the row). Only for
13
+ # apps running with ActiveSupport::IsolatedExecutionState.isolation_level = :fiber.
14
+ module HookRunner
15
+ def self.build(mode: BulkDestroyer.hook_mode)
16
+ case mode
17
+ when :fiber
18
+ if ActiveSupport::IsolatedExecutionState.isolation_level == :fiber
19
+ raise UnsupportedIsolation,
20
+ "isolation_level is :fiber, so each hook Fiber would get its own database connection. " \
21
+ "Set BulkDestroyer.hook_mode = :inline (after_destroy then runs just before the DELETE)."
22
+ end
23
+ FiberRunner.new
24
+ when :inline then InlineRunner.new
25
+ else raise ArgumentError, "unknown hook_mode #{mode.inspect}"
26
+ end
27
+ end
28
+
29
+ class FiberRunner
30
+ def initialize
31
+ @fibers = {}.compare_by_identity
32
+ # Thread.current[] values are fiber-local; copy them so hooks that read request-scoped
33
+ # state (RequestStore-style) see the same values inside the hook fibers.
34
+ @locals = Thread.current.keys.reject { |k| k.to_s.start_with?("__") }.to_h { |k| [k, Thread.current[k]] }
35
+ end
36
+
37
+ # => true when the chain reached the destroy step, false when a before_destroy halted it.
38
+ def start(record)
39
+ locals = @locals
40
+ fiber = Fiber.new do
41
+ locals.each { |k, v| Thread.current[k] = v }
42
+ record.run_callbacks(:destroy) do
43
+ Fiber.yield(:ready)
44
+ true
45
+ end
46
+ end
47
+ @fibers[record] = fiber
48
+ fiber.resume
49
+ return true if fiber.alive?
50
+
51
+ @fibers.delete(record)
52
+ false
53
+ end
54
+
55
+ def finish(record)
56
+ fiber = @fibers.delete(record)
57
+ fiber&.resume
58
+ end
59
+
60
+ # Unwinds fibers still suspended after a failure so around_destroy `ensure` blocks run.
61
+ def abort_all
62
+ @fibers.each_value do |fiber|
63
+ next unless fiber.alive?
64
+
65
+ begin
66
+ fiber.raise(Unwind)
67
+ rescue Unwind, StandardError
68
+ nil
69
+ end
70
+ end
71
+ @fibers.clear
72
+ end
73
+ end
74
+
75
+ class InlineRunner
76
+ def start(record)
77
+ record.run_callbacks(:destroy) { true } ? true : false
78
+ end
79
+
80
+ def finish(_record); end
81
+
82
+ def abort_all; end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ # Loads the dependent: :destroy graph below a batch of root records one level at a time:
5
+ # one preload query per association per level, never per record.
6
+ #
7
+ # A record reachable by several paths is placed at its deepest level, so it is always deleted
8
+ # before anything it references.
9
+ class Tree
10
+ def self.key_for(record)
11
+ [record.class.base_class, record.id]
12
+ end
13
+
14
+ # +exclude+: keys of records that belong to an enclosing batch. Descendants found under them are
15
+ # left alone (roots are always taken), so a nested pass never handles a record twice.
16
+ def initialize(roots, exclude: nil)
17
+ @roots = roots
18
+ @exclude = exclude || Set.new
19
+ @records = {}
20
+ @depths = {}
21
+ end
22
+
23
+ # => Array of Arrays; index 0 holds the roots, higher indexes are deeper.
24
+ def build
25
+ frontier = []
26
+ @roots.each { |root| add(root, 0, frontier) }
27
+
28
+ until frontier.empty?
29
+ current = frontier.uniq
30
+ frontier = []
31
+ current.group_by(&:class).each do |klass, owners|
32
+ expand(klass, owners) do |child, owner, via|
33
+ next if skipped?(child)
34
+
35
+ canonical = add(child, @depths.fetch(key(owner)) + 1, frontier)
36
+ canonical.destroyed_by_association = via if via
37
+ end
38
+ end
39
+ end
40
+
41
+ by_depth = Hash.new { |hash, depth| hash[depth] = [] }
42
+ @records.each { |k, record| by_depth[@depths.fetch(k)] << record }
43
+ by_depth.sort.map(&:last)
44
+ end
45
+
46
+ private
47
+
48
+ def key(record)
49
+ self.class.key_for(record)
50
+ end
51
+
52
+ def skipped?(record)
53
+ k = key(record)
54
+ !@records.key?(k) && @exclude.include?(k)
55
+ end
56
+
57
+ def add(record, depth, frontier)
58
+ k = key(record)
59
+ if (existing = @records[k])
60
+ return existing if @depths.fetch(k) >= depth
61
+
62
+ raise CycleDetected, "dependent: :destroy cycle detected at #{record.class.name}##{record.id}" if depth > @records.size
63
+
64
+ @depths[k] = depth
65
+ frontier << existing
66
+ existing
67
+ else
68
+ @records[k] = record
69
+ @depths[k] = depth
70
+ frontier << record
71
+ record
72
+ end
73
+ end
74
+
75
+ def expand(klass, owners)
76
+ plans = Dependents.plans_for(klass).select { |p| p.kind == :destroy || p.kind == :through_destroy }
77
+ return if plans.empty?
78
+
79
+ names = plans.map { |p| p.kind == :destroy ? p.reflection.name : p.reflection.through_reflection.name }.uniq
80
+ ActiveRecord::Associations::Preloader.new(records: owners, associations: names).call
81
+
82
+ plans.each do |plan|
83
+ direct = plan.kind == :destroy
84
+ name = direct ? plan.reflection.name : plan.reflection.through_reflection.name
85
+ owners.each do |owner|
86
+ Array(owner.association(name).target).each do |child|
87
+ yield child, owner, (plan.reflection if direct)
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BulkDestroyer
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+ require "active_support"
5
+ require "active_record"
6
+
7
+ require_relative "bulk_destroyer/version"
8
+ require_relative "bulk_destroyer/errors"
9
+ require_relative "bulk_destroyer/cascade_guard"
10
+ require_relative "bulk_destroyer/dependents"
11
+ require_relative "bulk_destroyer/tree"
12
+ require_relative "bulk_destroyer/hook_runner"
13
+ require_relative "bulk_destroyer/counter_caches"
14
+ require_relative "bulk_destroyer/batch"
15
+ require_relative "bulk_destroyer/destroyer"
16
+ require_relative "bulk_destroyer/api"
17
+
18
+ module BulkDestroyer
19
+ # Hard ceilings for IN (...) lists by adapter; the effective size is min(in_list_size, ceiling).
20
+ ADAPTER_IN_LIMITS = {
21
+ "oracleenhanced" => 1_000,
22
+ "sqlserver" => 2_000,
23
+ "sqlite" => 32_766
24
+ }.freeze
25
+ DEFAULT_IN_LIMIT = 65_000
26
+
27
+ class << self
28
+ # Parents loaded per transaction.
29
+ attr_accessor :batch_size
30
+ # Max values per `WHERE id IN (...)` chunk.
31
+ attr_accessor :in_list_size
32
+ # :fiber (default; after_destroy runs after the DELETE) or :inline (see HookRunner).
33
+ attr_accessor :hook_mode
34
+ # Most records (with destroy hooks) one batch may hold. :fiber mode keeps one paused Fiber per record,
35
+ # and Ruby on Linux fails (FiberError) at roughly 31,000 live Fibers with the default vm.max_map_count.
36
+ # A batch whose tree is larger is split in half until it fits; keep this well below that ceiling,
37
+ # because nested belongs_to target batches are counted separately.
38
+ attr_accessor :max_batch_records
39
+ # What to do when ONE root's tree alone exceeds max_batch_records: :raise (default, TreeTooLarge)
40
+ # or :inline (run that batch's hooks without Fibers, so its after_destroy hooks run before the DELETE).
41
+ attr_accessor :oversized_tree
42
+
43
+ def in_list_size_for(model)
44
+ # Model.with_connection arrived in Rails 7.2; 7.1 only has Model.connection.
45
+ name = model.respond_to?(:with_connection) ? model.with_connection(&:adapter_name) : model.connection.adapter_name
46
+ adapter = name.to_s.downcase.delete("_ ")
47
+ ceiling = ADAPTER_IN_LIMITS.find { |name, _| adapter.include?(name) }&.last || DEFAULT_IN_LIMIT
48
+ [in_list_size, ceiling].min
49
+ end
50
+ end
51
+
52
+ self.batch_size = 1_000
53
+ self.in_list_size = 500
54
+ self.hook_mode = :fiber
55
+ self.max_batch_records = 10_000
56
+ self.oversized_tree = :raise
57
+ end
58
+
59
+ ActiveSupport.on_load(:active_record) do
60
+ ActiveRecord::Relation.include BulkDestroyer::RelationMethods
61
+ ActiveRecord::Base.extend BulkDestroyer::ClassMethods
62
+ ActiveRecord::Base.include BulkDestroyer::RecordMethods
63
+
64
+ [ActiveRecord::Associations::HasManyAssociation,
65
+ ActiveRecord::Associations::HasOneAssociation,
66
+ ActiveRecord::Associations::BelongsToAssociation].each do |association|
67
+ association.prepend BulkDestroyer::CascadeGuard
68
+ end
69
+ end
metadata ADDED
@@ -0,0 +1,82 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bulk_destroyer
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Ramanan Neelakantan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-10-22 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '8.3'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '8.3'
32
+ description: |
33
+ BulkDestroyer destroys a relation (and its dependent: :destroy tree) while firing every
34
+ destroy callback on every record, but issues one SELECT per association per level and one
35
+ DELETE per table per level instead of one DELETE per row. Works on any ActiveRecord adapter.
36
+ email:
37
+ - ramanan.i.neelakantan@gmail.com
38
+ executables: []
39
+ extensions: []
40
+ extra_rdoc_files: []
41
+ files:
42
+ - CHANGELOG.md
43
+ - LICENSE
44
+ - README.md
45
+ - lib/bulk_destroyer.rb
46
+ - lib/bulk_destroyer/api.rb
47
+ - lib/bulk_destroyer/batch.rb
48
+ - lib/bulk_destroyer/cascade_guard.rb
49
+ - lib/bulk_destroyer/counter_caches.rb
50
+ - lib/bulk_destroyer/dependents.rb
51
+ - lib/bulk_destroyer/destroyer.rb
52
+ - lib/bulk_destroyer/errors.rb
53
+ - lib/bulk_destroyer/hook_runner.rb
54
+ - lib/bulk_destroyer/tree.rb
55
+ - lib/bulk_destroyer/version.rb
56
+ homepage: https://github.com/rneelakantan/bulk_destroyer
57
+ licenses:
58
+ - MIT
59
+ metadata:
60
+ source_code_uri: https://github.com/rneelakantan/bulk_destroyer
61
+ changelog_uri: https://github.com/rneelakantan/bulk_destroyer/blob/main/CHANGELOG.md
62
+ bug_tracker_uri: https://github.com/rneelakantan/bulk_destroyer/issues
63
+ rubygems_mfa_required: 'true'
64
+ rdoc_options: []
65
+ require_paths:
66
+ - lib
67
+ required_ruby_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '3.2'
72
+ required_rubygems_version: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ requirements: []
78
+ rubygems_version: 3.6.2
79
+ specification_version: 4
80
+ summary: 'destroy_all in bulk: every destroy callback fires, rows are deleted with
81
+ set-based SQL.'
82
+ test_files: []