console_think_twice 0.1.0 → 0.2.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: dccea38c9288544ae02eea01191a0ed7187ed9187f85e90fdfd37387ca656aa6
4
- data.tar.gz: 8bfd0ed5ca4daa26412dba1a43bbf9f2cb3090d49fc83aa9a99454771d20598b
3
+ metadata.gz: 4a6b253a3a4ae99d15459e9c03389d8939e9a9851372c2b7eadab198564ec7f1
4
+ data.tar.gz: '09efc4fd77651863aff72c3f578dc27ce30074cd96c190bd6a8da882c7b42e58'
5
5
  SHA512:
6
- metadata.gz: 869e288cb34127645618c7d0326ae421c1836f44c95f12295dbd057be52e178868397200559b544c4cca06fe02ba4dbf3ede34b90690d400585982d9cf172127
7
- data.tar.gz: effa7750ad3ba639698a3e37a8ecd4c789a2f3ce2abbe74bfc093150d91cbe6d23bafb37424938a3239720198d14a79611509b20b8d94428ed73fb35389dbd7e
6
+ metadata.gz: 3071ebb09d7533f2c733b487b94cfba26d895bf39ca9efd5a76718afdb703051f32c6fd34e1b4fdeccc7d171e7401a261315d2919ffde8542eb50a0c2ac9e5be
7
+ data.tar.gz: 9c2f40efb4837679488438af2f4d46169d75cca4e3410802a349d871f59effdcda4484792c3596f2d776031d2c9ff1a13c03637d2be2312c350bc20dcbc5f49e
data/CHANGELOG.md CHANGED
@@ -2,7 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.1.0]
5
+ ## [0.2.0] - 2026-08-26
6
+
7
+ ### Fixed
8
+
9
+ - Active Record's own destroys no longer prompt. A record marked `_destroy` by
10
+ `accepts_nested_attributes_for`, and the record a `has_one` assignment discards, were
11
+ asked about as though they had been typed — and asked from inside the enclosing call's
12
+ transaction, holding it and its locks open until somebody answered. Calls you type are
13
+ still guarded, including inside `transaction { ... }` and in a `--sandbox` console.
14
+ - `collection.destroy(record, other)` asks once for the whole call rather than once per
15
+ record, and asks before the transaction is opened rather than inside it.
16
+ - A blank `config.label` is left out of the prompt instead of printed. `false` rendered as
17
+ `"... Author #1false."`, and there was no way to turn the label off under Rails, where an
18
+ unset label falls back to `Rails.env`.
19
+ - `config.enabled` takes effect when it changes, rather than only at `install!` time.
20
+ - A forced call no longer counts the records it was told not to ask about, so
21
+ `destroy_all(force: true)` on a large table skips a needless `SELECT COUNT(*)`.
22
+ - A confirmed call stays confirmed inside a fiber. Suppression used `Thread.current[]`,
23
+ which is fiber-local, so a destroy reached through an Enumerator prompted again.
24
+
25
+ ### Changed
26
+
27
+ - `collection.delete(record)` is no longer guarded. It was only ever caught in passing, by
28
+ the guard on the record destroy underneath it, and what it means depends on the
29
+ association: without `dependent: :destroy` it unlinks the record rather than destroying
30
+ it, so the prompt could describe a permanent delete that was not one.
31
+
32
+ ### Added
33
+
34
+ - `ConsoleThinkTwice.enable!`, the counterpart to `disable!`.
35
+ - CI runs the suite against every supported Active Record, not only the newest.
36
+
37
+ ## [0.1.0] - 2026-08-26
6
38
 
7
39
  Initial release.
8
40
 
@@ -17,5 +49,6 @@ Initial release.
17
49
  - Non-interactive input refuses rather than assuming yes.
18
50
  - Configurable via `ConsoleThinkTwice.configure`, or turned off with `CONSOLE_THINK_TWICE=0`.
19
51
 
20
- [Unreleased]: https://github.com/agomezcampero/console-think-twice/compare/v0.1.0...HEAD
52
+ [Unreleased]: https://github.com/agomezcampero/console-think-twice/compare/v0.2.0...HEAD
53
+ [0.2.0]: https://github.com/agomezcampero/console-think-twice/compare/v0.1.0...v0.2.0
21
54
  [0.1.0]: https://github.com/agomezcampero/console-think-twice/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,6 @@
1
1
  # console_think_twice
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/console_think_twice.svg)](https://rubygems.org/gems/console_think_twice)
3
4
  [![CI](https://github.com/agomezcampero/console-think-twice/actions/workflows/ci.yml/badge.svg)](https://github.com/agomezcampero/console-think-twice/actions/workflows/ci.yml)
4
5
 
5
6
  Confirm before you destroy. `console_think_twice` intercepts destructive Active Record
@@ -27,14 +28,16 @@ Confirm? (y/N) y
27
28
 
28
29
  ## Installation
29
30
 
31
+ Add it to your Gemfile:
32
+
30
33
  ```ruby
31
34
  gem "console_think_twice"
32
35
  ```
33
36
 
34
- Not on RubyGems yet — until it is, point at the repository:
37
+ Then `bundle install`. Or:
35
38
 
36
- ```ruby
37
- gem "console_think_twice", github: "agomezcampero/console-think-twice"
39
+ ```bash
40
+ bundle add console_think_twice
38
41
  ```
39
42
 
40
43
  In a Rails app that is all — a railtie installs the guard from the `console` hook, which
@@ -51,6 +54,7 @@ Outside Rails, call `ConsoleThinkTwice.install!` once your models are loaded.
51
54
  | `record.delete` | `This will permanently delete, skipping callbacks, User #1.` |
52
55
  | `Model.destroy_all`, `relation.destroy_all`, `company.users.destroy_all` | `This will permanently destroy 200 Users.` |
53
56
  | `Model.delete_all`, `relation.delete_all` | `This will permanently delete, skipping callbacks, 200 Users.` |
57
+ | `company.users.destroy(user, other)` | `This will permanently destroy 2 Users.` |
54
58
 
55
59
  `Model.destroy(id)`, `destroy_by` and `delete_by` route through those methods, so they are
56
60
  covered too.
@@ -66,6 +70,20 @@ A `dependent: :destroy` cascade destroys child records, and `destroy_all` destro
66
70
  records one at a time — both would otherwise prompt again for every record. Nested prompts
67
71
  are suppressed for the duration of a confirmed call, so you are asked exactly once.
68
72
 
73
+ ### What is not guarded
74
+
75
+ `update_all`, `upsert_all` and anything run through `connection.execute` are untouched, as is
76
+ `collection.delete(record)` — which unlinks the record rather than destroying it, unless the
77
+ association says `dependent: :destroy`.
78
+
79
+ Active Record also destroys records itself, as one step of a call that is about something
80
+ else: a record marked `_destroy` by `accepts_nested_attributes_for`, or the old record a
81
+ `has_one` assignment discards. Those do not prompt. They are already covered by whatever you
82
+ typed to set them off, and they run inside that call's transaction — stopping to ask there
83
+ would hold the transaction, and its locks, open until somebody answered. A destroy you type
84
+ yourself is always asked about, including inside `transaction { ... }` and in a
85
+ `rails console --sandbox`.
86
+
69
87
  ### Nobody to ask
70
88
 
71
89
  When the input stream is not a terminal — piped input, a script fed to `rails console` —
@@ -77,7 +95,8 @@ there is no one to answer, so the guard refuses the call instead of assuming yes
77
95
  ```ruby
78
96
  ConsoleThinkTwice.configure do |config|
79
97
  config.enabled = Rails.env.production? # default: true, unless CONSOLE_THINK_TWICE is 0/false/no/off
80
- config.label = "production (europe)" # shown in the prompt; defaults to Rails.env
98
+ config.label = "production (europe)" # shown in the prompt; defaults to Rails.env,
99
+ # set nil or false to leave it out
81
100
  config.affirmative_answers = %w[y yes si] # default: %w[y yes]
82
101
  config.interactive = true # default: auto-detected from config.input.tty?
83
102
  config.input = $stdin
@@ -86,7 +105,9 @@ end
86
105
  ```
87
106
 
88
107
  Set `CONSOLE_THINK_TWICE=0` in the environment to turn the guard off without touching
89
- code. `ConsoleThinkTwice.disable!` turns it off for the rest of the current session.
108
+ code. `ConsoleThinkTwice.disable!` turns it off for the rest of the current session and
109
+ `ConsoleThinkTwice.enable!` turns it back on; setting `config.enabled` takes effect straight
110
+ away too, without reinstalling.
90
111
 
91
112
  ## Development
92
113
 
@@ -97,6 +118,13 @@ bundle exec rspec
97
118
  bundle exec standardrb
98
119
  ```
99
120
 
121
+ The guard reads Active Record's own call stack to tell your calls from its, so the suite is
122
+ run against every supported Active Record, not just the newest. Delete `Gemfile.lock`, then:
123
+
124
+ ```bash
125
+ ACTIVERECORD_VERSION=7.0 bundle install && ACTIVERECORD_VERSION=7.0 bundle exec rspec
126
+ ```
127
+
100
128
  ## License
101
129
 
102
130
  MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConsoleThinkTwice
4
+ # Guards the collection methods that name their records, rather than describing them with a
5
+ # scope. Prepended to CollectionProxy only: ActiveRecord::Relation has a `destroy` of its own
6
+ # that takes an id and means something different.
7
+ module CollectionGuard
8
+ # `books.destroy(a, b)` destroys the records it is handed one at a time. Asking here rather
9
+ # than once per record keeps it to a single prompt, and keeps the prompt outside the
10
+ # transaction Active Record opens to do the work.
11
+ def destroy(*records, force: false)
12
+ ConsoleThinkTwice.guard(self, action: :destroy, force: force, count: records.size) do
13
+ super(*records)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -35,9 +35,11 @@ module ConsoleThinkTwice
35
35
  input.respond_to?(:tty?) && input.tty?
36
36
  end
37
37
 
38
- # Shown in the prompt to make the stakes obvious, e.g. "in production".
38
+ # Shown in the prompt to make the stakes obvious, e.g. "in production". Defaults to the
39
+ # Rails environment; assigning any value at all — nil and false included — wins over that,
40
+ # so the label can be turned off as well as changed.
39
41
  def label
40
- return @label unless @label.nil?
42
+ return @label if defined?(@label)
41
43
 
42
44
  ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
43
45
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConsoleThinkTwice
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -7,6 +7,7 @@ require_relative "console_think_twice/version"
7
7
  require_relative "console_think_twice/configuration"
8
8
  require_relative "console_think_twice/record_guard"
9
9
  require_relative "console_think_twice/relation_guard"
10
+ require_relative "console_think_twice/collection_guard"
10
11
 
11
12
  # Confirmation prompts for destructive Active Record calls typed into a console.
12
13
  #
@@ -24,6 +25,17 @@ module ConsoleThinkTwice
24
25
  CASCADING_STRATEGIES = %i[destroy destroy_async delete_all].freeze
25
26
  SUPPRESSION_KEY = :console_think_twice_suppressed
26
27
 
28
+ # Source files belonging to Rails itself. Matched on the path rather than resolved from
29
+ # Gem.loaded_specs so that a vendored or git-checkout Rails is recognised too.
30
+ RAILS_SOURCE = %r{/active_(?:record|support|model)/}
31
+
32
+ # The Active Record internals that destroy a record as one step of a larger operation:
33
+ # association bookkeeping, autosave, and nested attributes.
34
+ RAILS_INTERNAL_WORK = %r{/active_record/(?:associations/|autosave_association\.rb|nested_attributes\.rb)}
35
+
36
+ # This gem's own frames, skipped when looking for the caller of a guarded method.
37
+ GEM_SOURCE = __dir__
38
+
27
39
  class << self
28
40
  def configuration
29
41
  @configuration ||= Configuration.new
@@ -38,8 +50,11 @@ module ConsoleThinkTwice
38
50
 
39
51
  unless @installed
40
52
  ActiveRecord::Base.prepend(RecordGuard)
53
+ # CollectionProxy overrides destroy_all and delete_all rather than inheriting them,
54
+ # so guarding Relation alone would miss every has_many collection.
41
55
  ActiveRecord::Relation.prepend(RelationGuard)
42
56
  ActiveRecord::Associations::CollectionProxy.prepend(RelationGuard)
57
+ ActiveRecord::Associations::CollectionProxy.prepend(CollectionGuard)
43
58
  @installed = true
44
59
  end
45
60
 
@@ -52,24 +67,73 @@ module ConsoleThinkTwice
52
67
  @active = false
53
68
  end
54
69
 
70
+ def enable!
71
+ @active = true
72
+ end
73
+
55
74
  def active?
56
- !!@active
75
+ !!@active && configuration.enabled?
57
76
  end
58
77
 
59
78
  # Confirms the call, then runs it with nested prompts suppressed so that a `dependent:`
60
79
  # cascade, or a relation destroying its records one by one, only ever asks once.
61
- def guard(target, action:, force:)
80
+ #
81
+ # `count` is passed when the caller already knows how many records are involved, which
82
+ # saves a COUNT and is the only way to size a call like `books.destroy(a, b)` that names
83
+ # its records rather than describing them.
84
+ def guard(target, action:, force:, count: nil)
62
85
  return yield if !active? || suppressed?
63
86
 
64
- count = affected_count(target)
65
- return yield if count.zero?
87
+ # Active Record's own work is confirmed by whatever the user typed to set it off, so it
88
+ # runs suppressed rather than merely unguarded: `destroy!` re-enters as `destroy`, and
89
+ # that second hop is dispatched from persistence.rb, where nothing marks it as internal.
90
+ return suppressed { yield } if nested_in_active_record?(target)
91
+
92
+ unless force
93
+ count ||= affected_count(target)
94
+ return yield if count.zero?
95
+
96
+ confirm!(target, action, count)
97
+ end
66
98
 
67
- confirm!(target, action, count) unless force
68
99
  suppressed { yield }
69
100
  end
70
101
 
71
102
  private
72
103
 
104
+ # True when Active Record itself made this call as one step of an operation already under
105
+ # way: a record being removed by `accepts_nested_attributes_for`, a `has_one` being
106
+ # replaced, a collection destroying the records it was handed. Those run inside the
107
+ # enclosing call's transaction, so prompting would hold that transaction — and its locks —
108
+ # open until somebody answers, and would ask about a record the user never named.
109
+ #
110
+ # Both conditions are required, because neither separates the two cases alone. A destroy
111
+ # typed inside `transaction { ... }`, or into a `--sandbox` console, has a transaction open
112
+ # but is still the user's own call; and `Model.destroy_all` is dispatched from inside
113
+ # Active Record, but only through the delegation in querying.rb rather than through the
114
+ # association and autosave code that does this work.
115
+ def nested_in_active_record?(target)
116
+ return false unless open_transaction?(target)
117
+
118
+ dispatch_chain.any? { |path| RAILS_INTERNAL_WORK.match?(path) }
119
+ end
120
+
121
+ # The frames Active Record itself put between the caller and us: everything above the first
122
+ # frame that belongs to neither this gem nor Rails, which is the code that made the call.
123
+ # An unrecognisable frame ends the chain, so anything we cannot read is treated as the
124
+ # caller's own and asked about rather than skipped.
125
+ def dispatch_chain
126
+ frames = (caller_locations(1, 50) || []).map { |location| location.path.to_s }
127
+ frames.drop_while { |path| path.start_with?(GEM_SOURCE) }
128
+ .take_while { |path| RAILS_SOURCE.match?(path) }
129
+ end
130
+
131
+ def open_transaction?(target)
132
+ model_of(target).connection.open_transactions > 0
133
+ rescue
134
+ false
135
+ end
136
+
73
137
  def confirm!(target, action, count)
74
138
  summary = summarize(target, action, count)
75
139
  unless configuration.interactive?
@@ -100,7 +164,9 @@ module ConsoleThinkTwice
100
164
  "#{count} #{(count == 1) ? model : model.pluralize}"
101
165
  end
102
166
 
103
- ["This will #{verb} #{subject}", configuration.label && " in #{configuration.label}", "."].compact.join
167
+ label = configuration.label
168
+ where = (label && !label.to_s.empty?) ? " in #{label}" : ""
169
+ "This will #{verb} #{subject}#{where}."
104
170
  end
105
171
 
106
172
  def affected_count(target)
@@ -119,16 +185,18 @@ module ConsoleThinkTwice
119
185
  .map(&:name)
120
186
  end
121
187
 
188
+ # Thread-local rather than fiber-local (which is what Thread.current[] gives), so that a
189
+ # confirmed call still counts as confirmed inside an Enumerator or any other fiber.
122
190
  def suppressed?
123
- Thread.current[SUPPRESSION_KEY]
191
+ Thread.current.thread_variable_get(SUPPRESSION_KEY)
124
192
  end
125
193
 
126
194
  def suppressed
127
- previous = Thread.current[SUPPRESSION_KEY]
128
- Thread.current[SUPPRESSION_KEY] = true
195
+ previous = Thread.current.thread_variable_get(SUPPRESSION_KEY)
196
+ Thread.current.thread_variable_set(SUPPRESSION_KEY, true)
129
197
  yield
130
198
  ensure
131
- Thread.current[SUPPRESSION_KEY] = previous
199
+ Thread.current.thread_variable_set(SUPPRESSION_KEY, previous)
132
200
  end
133
201
 
134
202
  def announce
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: console_think_twice
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Agustin Gomez
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-26 00:00:00.000000000 Z
11
+ date: 2026-08-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -51,6 +51,7 @@ files:
51
51
  - LICENSE
52
52
  - README.md
53
53
  - lib/console_think_twice.rb
54
+ - lib/console_think_twice/collection_guard.rb
54
55
  - lib/console_think_twice/configuration.rb
55
56
  - lib/console_think_twice/railtie.rb
56
57
  - lib/console_think_twice/record_guard.rb