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 +4 -4
- data/CHANGELOG.md +35 -2
- data/README.md +33 -5
- data/lib/console_think_twice/collection_guard.rb +17 -0
- data/lib/console_think_twice/configuration.rb +4 -2
- data/lib/console_think_twice/version.rb +1 -1
- data/lib/console_think_twice.rb +78 -10
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a6b253a3a4ae99d15459e9c03389d8939e9a9851372c2b7eadab198564ec7f1
|
|
4
|
+
data.tar.gz: '09efc4fd77651863aff72c3f578dc27ce30074cd96c190bd6a8da882c7b42e58'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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.
|
|
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
|
+
[](https://rubygems.org/gems/console_think_twice)
|
|
3
4
|
[](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
|
-
|
|
37
|
+
Then `bundle install`. Or:
|
|
35
38
|
|
|
36
|
-
```
|
|
37
|
-
|
|
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
|
|
42
|
+
return @label if defined?(@label)
|
|
41
43
|
|
|
42
44
|
::Rails.env.to_s if defined?(::Rails) && ::Rails.respond_to?(:env)
|
|
43
45
|
end
|
data/lib/console_think_twice.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
191
|
+
Thread.current.thread_variable_get(SUPPRESSION_KEY)
|
|
124
192
|
end
|
|
125
193
|
|
|
126
194
|
def suppressed
|
|
127
|
-
previous = Thread.current
|
|
128
|
-
Thread.current
|
|
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
|
|
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.
|
|
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-
|
|
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
|