console_think_twice 0.1.0 → 0.3.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: 236302689b1695ddb978506ea4a90c043c8cf03edb179b35ff053655c275297a
4
+ data.tar.gz: 84aab44ed6323c6fbbaa6cc1b2ef6872320335cd4cb8dbf6e86efb03f509a879
5
5
  SHA512:
6
- metadata.gz: 869e288cb34127645618c7d0326ae421c1836f44c95f12295dbd057be52e178868397200559b544c4cca06fe02ba4dbf3ede34b90690d400585982d9cf172127
7
- data.tar.gz: effa7750ad3ba639698a3e37a8ecd4c789a2f3ce2abbe74bfc093150d91cbe6d23bafb37424938a3239720198d14a79611509b20b8d94428ed73fb35389dbd7e
6
+ metadata.gz: 979ca9fbfa8533b0945653fc1207de763b4a80ddaac372acf37201d2ed0f4074c3686040d670d6b67ac3873ee408bf954935772e9b17e389e6a832a8cdd46817
7
+ data.tar.gz: 0e8e48c246db11faf5ab091e623789ef4b06253ddcf8e77bc8555df0e96310cb3e0835a79f849f8a565f73a65950617546e46f8ee91bec37e1ca29ad0e458580
data/CHANGELOG.md CHANGED
@@ -2,7 +2,48 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [0.1.0]
5
+ ## [0.3.0] - 2026-09-01
6
+
7
+ ### Added
8
+
9
+ - `config.ignored_classes` leaves the classes you name unguarded, for tables that are
10
+ cleared as a matter of routine — `SolidCache::Entry`, say. Takes names or classes,
11
+ covers subclasses, and compares names rather than resolving them, so a class that is not
12
+ loaded, or not in this app at all, can be listed.
13
+
14
+ ## [0.2.0] - 2026-08-26
15
+
16
+ ### Fixed
17
+
18
+ - Active Record's own destroys no longer prompt. A record marked `_destroy` by
19
+ `accepts_nested_attributes_for`, and the record a `has_one` assignment discards, were
20
+ asked about as though they had been typed — and asked from inside the enclosing call's
21
+ transaction, holding it and its locks open until somebody answered. Calls you type are
22
+ still guarded, including inside `transaction { ... }` and in a `--sandbox` console.
23
+ - `collection.destroy(record, other)` asks once for the whole call rather than once per
24
+ record, and asks before the transaction is opened rather than inside it.
25
+ - A blank `config.label` is left out of the prompt instead of printed. `false` rendered as
26
+ `"... Author #1false."`, and there was no way to turn the label off under Rails, where an
27
+ unset label falls back to `Rails.env`.
28
+ - `config.enabled` takes effect when it changes, rather than only at `install!` time.
29
+ - A forced call no longer counts the records it was told not to ask about, so
30
+ `destroy_all(force: true)` on a large table skips a needless `SELECT COUNT(*)`.
31
+ - A confirmed call stays confirmed inside a fiber. Suppression used `Thread.current[]`,
32
+ which is fiber-local, so a destroy reached through an Enumerator prompted again.
33
+
34
+ ### Changed
35
+
36
+ - `collection.delete(record)` is no longer guarded. It was only ever caught in passing, by
37
+ the guard on the record destroy underneath it, and what it means depends on the
38
+ association: without `dependent: :destroy` it unlinks the record rather than destroying
39
+ it, so the prompt could describe a permanent delete that was not one.
40
+
41
+ ### Added
42
+
43
+ - `ConsoleThinkTwice.enable!`, the counterpart to `disable!`.
44
+ - CI runs the suite against every supported Active Record, not only the newest.
45
+
46
+ ## [0.1.0] - 2026-08-26
6
47
 
7
48
  Initial release.
8
49
 
@@ -17,5 +58,7 @@ Initial release.
17
58
  - Non-interactive input refuses rather than assuming yes.
18
59
  - Configurable via `ConsoleThinkTwice.configure`, or turned off with `CONSOLE_THINK_TWICE=0`.
19
60
 
20
- [Unreleased]: https://github.com/agomezcampero/console-think-twice/compare/v0.1.0...HEAD
61
+ [Unreleased]: https://github.com/agomezcampero/console-think-twice/compare/v0.3.0...HEAD
62
+ [0.3.0]: https://github.com/agomezcampero/console-think-twice/compare/v0.2.0...v0.3.0
63
+ [0.2.0]: https://github.com/agomezcampero/console-think-twice/compare/v0.1.0...v0.2.0
21
64
  [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,22 @@ 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
+ Classes listed in `config.ignored_classes` are not guarded either — see below.
80
+
81
+ Active Record also destroys records itself, as one step of a call that is about something
82
+ else: a record marked `_destroy` by `accepts_nested_attributes_for`, or the old record a
83
+ `has_one` assignment discards. Those do not prompt. They are already covered by whatever you
84
+ typed to set them off, and they run inside that call's transaction — stopping to ask there
85
+ would hold the transaction, and its locks, open until somebody answered. A destroy you type
86
+ yourself is always asked about, including inside `transaction { ... }` and in a
87
+ `rails console --sandbox`.
88
+
69
89
  ### Nobody to ask
70
90
 
71
91
  When the input stream is not a terminal — piped input, a script fed to `rails console` —
@@ -77,7 +97,9 @@ there is no one to answer, so the guard refuses the call instead of assuming yes
77
97
  ```ruby
78
98
  ConsoleThinkTwice.configure do |config|
79
99
  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
100
+ config.label = "production (europe)" # shown in the prompt; defaults to Rails.env,
101
+ # set nil or false to leave it out
102
+ config.ignored_classes = %w[SolidCache::Entry] # left unguarded; default: none
81
103
  config.affirmative_answers = %w[y yes si] # default: %w[y yes]
82
104
  config.interactive = true # default: auto-detected from config.input.tty?
83
105
  config.input = $stdin
@@ -85,8 +107,24 @@ ConsoleThinkTwice.configure do |config|
85
107
  end
86
108
  ```
87
109
 
110
+ ### Classes to leave alone
111
+
112
+ Some tables are cleared as a matter of routine — job records, cache entries, whatever your
113
+ app treats as scratch. Being asked about those is noise, and noise teaches you to answer `y`
114
+ without reading. List them and the guard stays out of the way:
115
+
116
+ ```ruby
117
+ config.ignored_classes = %w[SolidCache::Entry SolidQueue::Job]
118
+ ```
119
+
120
+ Classes work as well as names, and a subclass of a listed class is ignored too. Names are
121
+ compared rather than resolved, so listing a class that this app does not have, or has not
122
+ autoloaded yet, is fine.
123
+
88
124
  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.
125
+ code. `ConsoleThinkTwice.disable!` turns it off for the rest of the current session and
126
+ `ConsoleThinkTwice.enable!` turns it back on; setting `config.enabled` takes effect straight
127
+ away too, without reinstalling.
90
128
 
91
129
  ## Development
92
130
 
@@ -97,6 +135,13 @@ bundle exec rspec
97
135
  bundle exec standardrb
98
136
  ```
99
137
 
138
+ The guard reads Active Record's own call stack to tell your calls from its, so the suite is
139
+ run against every supported Active Record, not just the newest. Delete `Gemfile.lock`, then:
140
+
141
+ ```bash
142
+ ACTIVERECORD_VERSION=7.0 bundle install && ACTIVERECORD_VERSION=7.0 bundle exec rspec
143
+ ```
144
+
100
145
  ## License
101
146
 
102
147
  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
@@ -21,6 +21,27 @@ module ConsoleThinkTwice
21
21
  @output || $stdout
22
22
  end
23
23
 
24
+ # Classes the guard leaves alone. Bookkeeping tables that are cleared as a matter of
25
+ # routine — `SolidCache::Entry`, say — are noise to be asked about, and being asked
26
+ # about them teaches you to answer y without reading. Takes names or classes, and a
27
+ # subclass of a listed class is ignored too.
28
+ #
29
+ # Names are compared rather than resolved, so a class can be listed before it is
30
+ # autoloaded, or listed in an initializer shared by apps that do not all have it.
31
+ def ignored_classes=(classes)
32
+ @ignored_classes = Array(classes).map(&:to_s)
33
+ end
34
+
35
+ def ignored_classes
36
+ @ignored_classes ||= []
37
+ end
38
+
39
+ def ignores?(model)
40
+ return false if ignored_classes.empty?
41
+
42
+ inheritance_chain(model).any? { |name| ignored_classes.include?(name) }
43
+ end
44
+
24
45
  def enabled?
25
46
  return @enabled unless @enabled.nil?
26
47
 
@@ -35,11 +56,23 @@ module ConsoleThinkTwice
35
56
  input.respond_to?(:tty?) && input.tty?
36
57
  end
37
58
 
38
- # Shown in the prompt to make the stakes obvious, e.g. "in production".
59
+ # Shown in the prompt to make the stakes obvious, e.g. "in production". Defaults to the
60
+ # Rails environment; assigning any value at all — nil and false included — wins over that,
61
+ # so the label can be turned off as well as changed.
39
62
  def label
40
- return @label unless @label.nil?
63
+ return @label if defined?(@label)
41
64
 
42
65
  ::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
43
66
  end
67
+
68
+ private
69
+
70
+ # The model's own name and the names of the models it inherits from, so that ignoring a
71
+ # class ignores its subclasses. Stops at Active Record itself, which every model shares.
72
+ def inheritance_chain(model)
73
+ Enumerator.produce(model, &:superclass)
74
+ .take_while { |klass| klass && klass != ::ActiveRecord::Base }
75
+ .map(&:name)
76
+ end
44
77
  end
45
78
  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.3.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,77 @@ 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:)
62
- return yield if !active? || suppressed?
63
-
64
- count = affected_count(target)
65
- return yield if count.zero?
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)
85
+ return yield if !active? || suppressed? || ignored?(target)
86
+
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
+ def ignored?(target)
105
+ configuration.ignores?(model_of(target))
106
+ end
107
+
108
+ # True when Active Record itself made this call as one step of an operation already under
109
+ # way: a record being removed by `accepts_nested_attributes_for`, a `has_one` being
110
+ # replaced, a collection destroying the records it was handed. Those run inside the
111
+ # enclosing call's transaction, so prompting would hold that transaction — and its locks —
112
+ # open until somebody answers, and would ask about a record the user never named.
113
+ #
114
+ # Both conditions are required, because neither separates the two cases alone. A destroy
115
+ # typed inside `transaction { ... }`, or into a `--sandbox` console, has a transaction open
116
+ # but is still the user's own call; and `Model.destroy_all` is dispatched from inside
117
+ # Active Record, but only through the delegation in querying.rb rather than through the
118
+ # association and autosave code that does this work.
119
+ def nested_in_active_record?(target)
120
+ return false unless open_transaction?(target)
121
+
122
+ dispatch_chain.any? { |path| RAILS_INTERNAL_WORK.match?(path) }
123
+ end
124
+
125
+ # The frames Active Record itself put between the caller and us: everything above the first
126
+ # frame that belongs to neither this gem nor Rails, which is the code that made the call.
127
+ # An unrecognisable frame ends the chain, so anything we cannot read is treated as the
128
+ # caller's own and asked about rather than skipped.
129
+ def dispatch_chain
130
+ frames = (caller_locations(1, 50) || []).map { |location| location.path.to_s }
131
+ frames.drop_while { |path| path.start_with?(GEM_SOURCE) }
132
+ .take_while { |path| RAILS_SOURCE.match?(path) }
133
+ end
134
+
135
+ def open_transaction?(target)
136
+ model_of(target).connection.open_transactions > 0
137
+ rescue
138
+ false
139
+ end
140
+
73
141
  def confirm!(target, action, count)
74
142
  summary = summarize(target, action, count)
75
143
  unless configuration.interactive?
@@ -100,7 +168,9 @@ module ConsoleThinkTwice
100
168
  "#{count} #{(count == 1) ? model : model.pluralize}"
101
169
  end
102
170
 
103
- ["This will #{verb} #{subject}", configuration.label && " in #{configuration.label}", "."].compact.join
171
+ label = configuration.label
172
+ where = (label && !label.to_s.empty?) ? " in #{label}" : ""
173
+ "This will #{verb} #{subject}#{where}."
104
174
  end
105
175
 
106
176
  def affected_count(target)
@@ -119,16 +189,18 @@ module ConsoleThinkTwice
119
189
  .map(&:name)
120
190
  end
121
191
 
192
+ # Thread-local rather than fiber-local (which is what Thread.current[] gives), so that a
193
+ # confirmed call still counts as confirmed inside an Enumerator or any other fiber.
122
194
  def suppressed?
123
- Thread.current[SUPPRESSION_KEY]
195
+ Thread.current.thread_variable_get(SUPPRESSION_KEY)
124
196
  end
125
197
 
126
198
  def suppressed
127
- previous = Thread.current[SUPPRESSION_KEY]
128
- Thread.current[SUPPRESSION_KEY] = true
199
+ previous = Thread.current.thread_variable_get(SUPPRESSION_KEY)
200
+ Thread.current.thread_variable_set(SUPPRESSION_KEY, true)
129
201
  yield
130
202
  ensure
131
- Thread.current[SUPPRESSION_KEY] = previous
203
+ Thread.current.thread_variable_set(SUPPRESSION_KEY, previous)
132
204
  end
133
205
 
134
206
  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.3.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-09-01 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