safe_migrations 1.0.4 → 1.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 +4 -4
- data/.gitignore +3 -0
- data/.rubocop.yml +9 -0
- data/.ruby-version +1 -1
- data/CHANGELOG.md +10 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +22 -11
- data/README.md +76 -4
- data/Rakefile +7 -0
- data/bin/release +92 -0
- data/lib/safe_migrations/command_recorder_extension.rb +36 -25
- data/lib/safe_migrations/dry_run.rb +62 -0
- data/lib/safe_migrations/migration_helper.rb +35 -21
- data/lib/safe_migrations/version.rb +3 -1
- data/lib/safe_migrations.rb +2 -0
- data/safe_migrations.gemspec +6 -7
- metadata +6 -62
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3ef83718af52c34c752b51c61a5e8fb690fd9530299898c300ac51ba54d17341
|
|
4
|
+
data.tar.gz: 84a966d12c263a36fc8b8a9bce27e2d2589e21f66647c7e743f4480350c4906f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7918fb6c428ec15e024aeb8fabef6a1134f749f544df4a6b53643004ea3b0dec5f54e1bbea5ef7141942aa7542bf4a2aa94c905c22617750ff9201bd6186146a
|
|
7
|
+
data.tar.gz: b2f6952bfb9474d17f2eaa6c9ab544cdb87f959f1f5cab4e59eb736353fff4f513d723e8583c4bc36df2845921a87f80f817385201643fc49b5d667f388d05e5
|
data/.gitignore
CHANGED
data/.rubocop.yml
CHANGED
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
4.0.6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-09-08
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Class-level `dry_runnable` option to automatically wrap upward `up` and `change` execution.
|
|
12
|
+
- `dry_runnable { ... }` block helper and `dry_run?` predicate, controlled by `DRY_RUN`.
|
|
13
|
+
- Dry runs execute real database changes, then roll back through Rails' migration transaction and leave the migration pending.
|
|
14
|
+
- Guards reject dry runs when DDL transactions are disabled, unsupported, or no transaction is active.
|
|
15
|
+
|
|
7
16
|
## [1.0.0] - 2025-10-24
|
|
8
17
|
|
|
9
18
|
### Added
|
|
@@ -14,3 +23,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
|
14
23
|
- N/A (initial release)
|
|
15
24
|
|
|
16
25
|
[1.0.0]: https://github.com/moskvin/safe_migrations/releases/tag/v1.0.0
|
|
26
|
+
[1.1.0]: https://github.com/moskvin/safe_migrations/releases/tag/v1.1.0
|
data/Gemfile
CHANGED
|
@@ -7,6 +7,12 @@ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
|
|
|
7
7
|
# Specify your gem's dependencies in safe_migrations.gemspec
|
|
8
8
|
gemspec
|
|
9
9
|
|
|
10
|
+
gem 'bundler', '~> 2.7'
|
|
11
|
+
gem 'rake', '~> 13.0'
|
|
12
|
+
gem 'rubocop'
|
|
13
|
+
gem 'rubocop-rake'
|
|
14
|
+
gem 'rubocop-rspec'
|
|
15
|
+
|
|
10
16
|
group :test do
|
|
11
17
|
gem 'rspec', '~> 3.12'
|
|
12
18
|
gem 'sqlite3', '~> 2.7'
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
safe_migrations (1.0
|
|
4
|
+
safe_migrations (1.1.0)
|
|
5
5
|
activerecord (>= 7.0)
|
|
6
6
|
|
|
7
7
|
GEM
|
|
@@ -40,12 +40,14 @@ GEM
|
|
|
40
40
|
lint_roller (1.1.0)
|
|
41
41
|
logger (1.7.0)
|
|
42
42
|
mini_portile2 (2.8.9)
|
|
43
|
-
minitest (
|
|
43
|
+
minitest (6.0.6)
|
|
44
|
+
drb (~> 2.0)
|
|
45
|
+
prism (~> 1.5)
|
|
44
46
|
parallel (1.27.0)
|
|
45
47
|
parser (3.3.9.0)
|
|
46
48
|
ast (~> 2.4.1)
|
|
47
49
|
racc
|
|
48
|
-
prism (1.
|
|
50
|
+
prism (1.9.0)
|
|
49
51
|
racc (1.8.1)
|
|
50
52
|
rainbow (3.1.1)
|
|
51
53
|
rake (13.3.0)
|
|
@@ -63,20 +65,27 @@ GEM
|
|
|
63
65
|
diff-lcs (>= 1.2.0, < 2.0)
|
|
64
66
|
rspec-support (~> 3.13.0)
|
|
65
67
|
rspec-support (3.13.6)
|
|
66
|
-
rubocop (1.
|
|
67
|
-
json (
|
|
68
|
+
rubocop (1.90.0)
|
|
69
|
+
json (>= 2.3)
|
|
68
70
|
language_server-protocol (~> 3.17.0.2)
|
|
69
71
|
lint_roller (~> 1.1.0)
|
|
70
|
-
parallel (
|
|
72
|
+
parallel (>= 1.10)
|
|
71
73
|
parser (>= 3.3.0.2)
|
|
72
74
|
rainbow (>= 2.2.2, < 4.0)
|
|
73
75
|
regexp_parser (>= 2.9.3, < 3.0)
|
|
74
|
-
rubocop-ast (>= 1.
|
|
76
|
+
rubocop-ast (>= 1.49.0, < 2.0)
|
|
75
77
|
ruby-progressbar (~> 1.7)
|
|
76
78
|
unicode-display_width (>= 2.4.0, < 4.0)
|
|
77
|
-
rubocop-ast (1.
|
|
79
|
+
rubocop-ast (1.50.0)
|
|
78
80
|
parser (>= 3.3.7.2)
|
|
79
|
-
prism (~> 1.
|
|
81
|
+
prism (~> 1.7)
|
|
82
|
+
rubocop-rake (0.7.1)
|
|
83
|
+
lint_roller (~> 1.1)
|
|
84
|
+
rubocop (>= 1.72.1)
|
|
85
|
+
rubocop-rspec (3.10.2)
|
|
86
|
+
lint_roller (~> 1.1)
|
|
87
|
+
regexp_parser (>= 2.0)
|
|
88
|
+
rubocop (~> 1.86, >= 1.86.2)
|
|
80
89
|
ruby-progressbar (1.13.0)
|
|
81
90
|
securerandom (0.4.1)
|
|
82
91
|
sqlite3 (2.7.4)
|
|
@@ -96,7 +105,7 @@ GEM
|
|
|
96
105
|
concurrent-ruby (~> 1.0)
|
|
97
106
|
unicode-display_width (3.2.0)
|
|
98
107
|
unicode-emoji (~> 4.1)
|
|
99
|
-
unicode-emoji (4.
|
|
108
|
+
unicode-emoji (4.2.0)
|
|
100
109
|
uri (1.0.4)
|
|
101
110
|
|
|
102
111
|
PLATFORMS
|
|
@@ -115,8 +124,10 @@ PLATFORMS
|
|
|
115
124
|
DEPENDENCIES
|
|
116
125
|
bundler (~> 2.7)
|
|
117
126
|
rake (~> 13.0)
|
|
118
|
-
rspec (~> 3.12
|
|
127
|
+
rspec (~> 3.12)
|
|
119
128
|
rubocop
|
|
129
|
+
rubocop-rake
|
|
130
|
+
rubocop-rspec
|
|
120
131
|
safe_migrations!
|
|
121
132
|
sqlite3 (~> 2.7)
|
|
122
133
|
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
G'day, mate! Welcome to **SafeMigrations**, a ripper of a gem that makes your Rails migrations as safe as a kangaroo in the outback. With `safe_` prefixed methods, this gem ensures your database schema changes are idempotent—no dramas if you run 'em twice. Built to play nice with Rails' `CommandRecorder`, it auto-reverses your migrations in the `change` method, so you can crack on with building your app without worrying about dodgy rollbacks.
|
|
4
4
|
|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
[](https://badge.fury.io/rb/safe_migrations)
|
|
8
8
|
[](https://github.com/moskvin/safe_migrations/actions)
|
|
@@ -61,6 +61,64 @@ end
|
|
|
61
61
|
- **Rolling Back**: `rails db:rollback` inverts to `safe_drop_table`, `safe_remove_column`, `safe_remove_index` (in reverse order).
|
|
62
62
|
- **Heads Up**: If a table exists before the migration, `safe_create_table` skips it, but rollback may still call `safe_drop_table`. For critical cases, use `reversible`:
|
|
63
63
|
|
|
64
|
+
### Dry runs (v1.1+)
|
|
65
|
+
|
|
66
|
+
Declare `dry_runnable` once to wrap the entire upward migration automatically:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
class CleanupBrokenLinks < ActiveRecord::Migration[8.0]
|
|
70
|
+
dry_runnable
|
|
71
|
+
|
|
72
|
+
def up
|
|
73
|
+
# All database writes here run normally
|
|
74
|
+
Link.where(broken: true).destroy_all
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def down
|
|
78
|
+
raise ActiveRecord::IrreversibleMigration
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
DRY_RUN=1 bundle exec rails db:migrate:up VERSION=20260817090000
|
|
85
|
+
# Apply for real:
|
|
86
|
+
bundle exec rails db:migrate:up VERSION=20260817090000
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With `DRY_RUN=1`, the body executes real SQL so counts and reports reflect real work.
|
|
90
|
+
An `ActiveRecord::Rollback` then reaches Rails' migration transaction, undoing the
|
|
91
|
+
writes and skipping the migration-version record: the migration remains pending.
|
|
92
|
+
Unset `DRY_RUN` (or set it to `0` or `false`) for normal execution.
|
|
93
|
+
|
|
94
|
+
The declaration is inherited and also supports `change` on upward execution;
|
|
95
|
+
`down` and reversal of `change` are unaffected. Only opted-in migrations are wrapped.
|
|
96
|
+
For existing migrations, the block form remains available:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
def up
|
|
100
|
+
dry_runnable do
|
|
101
|
+
# Database writes and reporting.
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`dry_run?` exposes the environment flag. Dry runs require Rails' migration runner,
|
|
107
|
+
an active transaction, and an adapter supporting DDL transactions (such as PostgreSQL
|
|
108
|
+
or SQLite). They reject `disable_ddl_transaction!` and unsupported adapters before
|
|
109
|
+
executing the wrapped body. Direct calls to `up` bypass the class-level wrapper.
|
|
110
|
+
Do not rescue `ActiveRecord::Rollback` or swallow it in an enclosing transaction
|
|
111
|
+
around the block helper: it must reach the migration runner to leave the version pending.
|
|
112
|
+
|
|
113
|
+
Rollback covers writes on the migration connection, not API calls, files, Ruby state,
|
|
114
|
+
or writes on other database connections. Database sequences may still advance.
|
|
115
|
+
Prefer targeting one migration: a full migration run can continue to later migrations
|
|
116
|
+
after rollback, and migrations without the option still apply normally.
|
|
117
|
+
|
|
118
|
+
When moving from an application-defined helper, remove its `dry_runnable` and
|
|
119
|
+
`dry_run?` definitions so the gem supplies them. Application-specific helpers such
|
|
120
|
+
as PaperTrail's `setup_version` stay in the application.
|
|
121
|
+
|
|
64
122
|
## Development
|
|
65
123
|
|
|
66
124
|
No worries, mate! To get started:
|
|
@@ -75,9 +133,23 @@ To install the gem locally:
|
|
|
75
133
|
$ bundle exec rake install
|
|
76
134
|
```
|
|
77
135
|
|
|
78
|
-
To release a new version
|
|
79
|
-
|
|
80
|
-
|
|
136
|
+
To release a new version, update `lib/safe_migrations/version.rb` and the changelog,
|
|
137
|
+
update the lockfile, then commit your changes and run:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
bundle exec rake release:guarded
|
|
141
|
+
# Equivalent:
|
|
142
|
+
bin/release
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The helper requires a clean worktree and the default branch (`origin/HEAD`, falling
|
|
146
|
+
back to the current branch). It fetches origin, rejects a behind or diverged branch,
|
|
147
|
+
and checks that the version tag does not already exist locally or remotely.
|
|
148
|
+
It then runs specs and RuboCop, pushes the branch, and invokes Bundler's
|
|
149
|
+
`bundle exec rake release` to build, tag, push, and publish to RubyGems.
|
|
150
|
+
|
|
151
|
+
Use `bin/release --skip-checks` to skip specs and lint while retaining the git and
|
|
152
|
+
version checks. `bin/release --help` displays usage without releasing anything.
|
|
81
153
|
|
|
82
154
|
## Testing
|
|
83
155
|
|
data/Rakefile
CHANGED
|
@@ -6,3 +6,10 @@ require 'rspec/core/rake_task'
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
8
|
task default: :spec
|
|
9
|
+
|
|
10
|
+
namespace :release do
|
|
11
|
+
desc 'Check git, run specs and lint, push the branch, then release to RubyGems'
|
|
12
|
+
task :guarded do
|
|
13
|
+
sh File.expand_path('bin/release', __dir__)
|
|
14
|
+
end
|
|
15
|
+
end
|
data/bin/release
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
IFS=$'\n\t'
|
|
4
|
+
|
|
5
|
+
root_dir=$(cd "$(dirname "$0")/.." && pwd)
|
|
6
|
+
cd "$root_dir"
|
|
7
|
+
|
|
8
|
+
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
|
9
|
+
cat <<'USAGE'
|
|
10
|
+
Usage: bin/release [--skip-checks]
|
|
11
|
+
|
|
12
|
+
Runs guardrails before releasing:
|
|
13
|
+
1. Requires a clean git worktree
|
|
14
|
+
2. Requires current branch to match origin/HEAD (or local HEAD fallback)
|
|
15
|
+
3. Requires branch to be in sync with origin
|
|
16
|
+
4. Runs test + lint checks (unless --skip-checks)
|
|
17
|
+
5. Pushes the branch, then runs `bundle exec rake release`
|
|
18
|
+
USAGE
|
|
19
|
+
exit 0
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
skip_checks=false
|
|
23
|
+
if [[ $# -gt 1 ]]; then
|
|
24
|
+
echo "Expected at most one option. Use --help for usage." >&2
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
if [[ "${1:-}" == "--skip-checks" ]]; then
|
|
29
|
+
skip_checks=true
|
|
30
|
+
elif [[ $# -gt 0 ]]; then
|
|
31
|
+
echo "Unknown option: $1" >&2
|
|
32
|
+
exit 1
|
|
33
|
+
fi
|
|
34
|
+
|
|
35
|
+
if [[ -n "$(git status --porcelain)" ]]; then
|
|
36
|
+
echo "Working tree is not clean:" >&2
|
|
37
|
+
git status --short >&2
|
|
38
|
+
echo "Commit or stash changes before releasing. Plain git stash excludes untracked files; use git stash -u to include them." >&2
|
|
39
|
+
exit 1
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
|
43
|
+
default_branch=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')
|
|
44
|
+
default_branch=${default_branch:-$current_branch}
|
|
45
|
+
|
|
46
|
+
if [[ "$current_branch" != "$default_branch" ]]; then
|
|
47
|
+
echo "Release must run from '$default_branch' (current: '$current_branch')." >&2
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
echo "Fetching latest refs from origin..."
|
|
52
|
+
git fetch origin
|
|
53
|
+
|
|
54
|
+
local_sha=$(git rev-parse @)
|
|
55
|
+
remote_sha=$(git rev-parse "origin/$default_branch")
|
|
56
|
+
base_sha=$(git merge-base @ "origin/$default_branch")
|
|
57
|
+
|
|
58
|
+
if [[ "$local_sha" == "$remote_sha" ]]; then
|
|
59
|
+
echo "Branch is in sync with origin/$default_branch."
|
|
60
|
+
elif [[ "$local_sha" == "$base_sha" ]]; then
|
|
61
|
+
echo "Local branch is behind origin/$default_branch. Pull/rebase first." >&2
|
|
62
|
+
exit 1
|
|
63
|
+
elif [[ "$remote_sha" == "$base_sha" ]]; then
|
|
64
|
+
echo "Local branch is ahead of origin/$default_branch and will be pushed."
|
|
65
|
+
else
|
|
66
|
+
echo "Local and origin/$default_branch have diverged. Rebase/merge first." >&2
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
version=$(ruby -e 'require_relative "lib/safe_migrations/version"; puts SafeMigrations::VERSION')
|
|
71
|
+
tag="v$version"
|
|
72
|
+
|
|
73
|
+
if git rev-parse "$tag" >/dev/null 2>&1 || git ls-remote --tags origin "$tag" | grep -q "$tag"; then
|
|
74
|
+
echo "Tag '$tag' already exists locally or on origin. Bump version first." >&2
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
if [[ "$skip_checks" == "false" ]]; then
|
|
79
|
+
echo "Running specs..."
|
|
80
|
+
bundle exec rspec
|
|
81
|
+
|
|
82
|
+
echo "Running RuboCop..."
|
|
83
|
+
bundle exec rubocop
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
echo "Pushing branch '$default_branch' first..."
|
|
87
|
+
git push origin "$default_branch"
|
|
88
|
+
|
|
89
|
+
echo "Releasing gem with rake task..."
|
|
90
|
+
bundle exec rake release
|
|
91
|
+
|
|
92
|
+
echo "Release complete: $tag"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SafeMigrations
|
|
4
|
+
# Records safe schema operations and supplies their inverses.
|
|
4
5
|
module CommandRecorderExtension
|
|
5
6
|
SAFE_METHODS = %i[
|
|
6
7
|
safe_create_table
|
|
@@ -41,37 +42,47 @@ module SafeMigrations
|
|
|
41
42
|
}.freeze
|
|
42
43
|
|
|
43
44
|
def self.apply
|
|
44
|
-
ActiveRecord::Migration::CommandRecorder
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
ruby2_keywords(method)
|
|
50
|
-
end
|
|
45
|
+
recorder = ActiveRecord::Migration::CommandRecorder
|
|
46
|
+
register_commands(recorder)
|
|
47
|
+
register_inverses(recorder)
|
|
48
|
+
recorder.prepend(SpecialInversions)
|
|
49
|
+
end
|
|
51
50
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
def self.register_commands(recorder)
|
|
52
|
+
SAFE_METHODS.each do |method|
|
|
53
|
+
recorder.define_method(method) do |*args, &block|
|
|
54
|
+
record(method, args, &block)
|
|
56
55
|
end
|
|
56
|
+
recorder.send(:ruby2_keywords, method)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
[
|
|
60
|
+
def self.register_inverses(recorder)
|
|
61
|
+
SAFE_REVERSIBLE_MAP.each do |cmd, inv|
|
|
62
|
+
recorder.define_method(:"invert_#{cmd}") do |args, &block|
|
|
63
|
+
[inv, args, block]
|
|
62
64
|
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
63
67
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
# Operations whose inverses require transformed arguments.
|
|
69
|
+
module SpecialInversions
|
|
70
|
+
# Special case for safe_rename_column (swap column names)
|
|
71
|
+
def invert_safe_rename_column(args)
|
|
72
|
+
table, old_col, new_col = args
|
|
73
|
+
[:safe_rename_column, [table, new_col, old_col]]
|
|
74
|
+
end
|
|
69
75
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
# Special case for safe_change_column_null (needs invert value)
|
|
77
|
+
def invert_safe_change_column_null(args)
|
|
78
|
+
table, column, value = args
|
|
79
|
+
[:safe_change_column_null, [table, column, !value]]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def invert_safe_change_column_default(args)
|
|
83
|
+
table, column, extra = args
|
|
84
|
+
reverted_extra = { to: extra[:from], from: extra[:to] }
|
|
85
|
+
[:safe_change_column_default, [table, column, reverted_extra]]
|
|
75
86
|
end
|
|
76
87
|
end
|
|
77
88
|
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMigrations
|
|
4
|
+
# Runs opted-in migrations inside the migrator's rollback boundary.
|
|
5
|
+
module DryRun
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.class_attribute(:safe_migrations_dry_runnable, instance_accessor: false, default: false)
|
|
8
|
+
base.extend(ClassMethods)
|
|
9
|
+
base.prepend(Execution)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Inheritable migration opt-in.
|
|
13
|
+
module ClassMethods
|
|
14
|
+
def dry_runnable
|
|
15
|
+
self.safe_migrations_dry_runnable = true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Wraps Rails execution without changing downward migrations.
|
|
20
|
+
module Execution
|
|
21
|
+
def exec_migration(conn, direction)
|
|
22
|
+
if direction == :up && self.class.safe_migrations_dry_runnable
|
|
23
|
+
perform_dry_run(conn) { super }
|
|
24
|
+
else
|
|
25
|
+
super
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def dry_run?
|
|
31
|
+
!!ActiveModel::Type::Boolean.new.cast(ENV.fetch('DRY_RUN', nil))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def dry_runnable(&)
|
|
35
|
+
perform_dry_run(connection, &)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def perform_dry_run(conn)
|
|
41
|
+
return yield unless dry_run?
|
|
42
|
+
|
|
43
|
+
validate_dry_run_transaction!(conn)
|
|
44
|
+
say 'DRY_RUN: executing inside the migration transaction'
|
|
45
|
+
yield
|
|
46
|
+
say 'DRY_RUN: rolling back; migration will remain pending'
|
|
47
|
+
# This must reach the migrator's transaction, before it records the version.
|
|
48
|
+
raise ActiveRecord::Rollback
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate_dry_run_transaction!(conn)
|
|
52
|
+
if self.class.disable_ddl_transaction || !conn.supports_ddl_transactions?
|
|
53
|
+
raise SafeMigrations::Error,
|
|
54
|
+
'DRY_RUN requires a migration with DDL transactions enabled and an adapter that supports them'
|
|
55
|
+
end
|
|
56
|
+
return if conn.transaction_open?
|
|
57
|
+
|
|
58
|
+
raise SafeMigrations::Error,
|
|
59
|
+
'DRY_RUN requires an active migration transaction; run through Rails migration tasks'
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -1,34 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module SafeMigrations
|
|
4
|
+
# Adds idempotent schema operations to database adapters.
|
|
2
5
|
module MigrationHelper
|
|
3
6
|
def self.included(base)
|
|
4
7
|
base.send(:include, InstanceMethods)
|
|
5
8
|
end
|
|
6
9
|
|
|
10
|
+
# Checks schema state before applying each operation.
|
|
7
11
|
module InstanceMethods
|
|
8
|
-
def safe_add_column(table, column, type, **
|
|
12
|
+
def safe_add_column(table, column, type, **)
|
|
9
13
|
return unless table_exists?(table)
|
|
10
14
|
return if column_exists?(table, column)
|
|
11
15
|
|
|
12
|
-
add_column(table, column, type, **
|
|
16
|
+
add_column(table, column, type, **)
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
def safe_remove_column(table, column, type = nil, **
|
|
16
|
-
table_exists?(table) && column_exists?(table, column) && remove_column(table, column, type, **
|
|
19
|
+
def safe_remove_column(table, column, type = nil, **)
|
|
20
|
+
table_exists?(table) && column_exists?(table, column) && remove_column(table, column, type, **)
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def safe_rename_column(table_name, column_name, new_column_name)
|
|
20
|
-
column_exists?(table_name, column_name) &&
|
|
24
|
+
column_exists?(table_name, column_name) &&
|
|
25
|
+
!column_exists?(table_name, new_column_name) &&
|
|
26
|
+
rename_column(table_name, column_name, new_column_name)
|
|
21
27
|
end
|
|
22
28
|
|
|
23
|
-
def safe_add_index(table, column, **
|
|
29
|
+
def safe_add_index(table, column, **)
|
|
24
30
|
return unless table_exists?(table)
|
|
25
|
-
return if index_exists?(table, column, **
|
|
31
|
+
return if index_exists?(table, column, **)
|
|
26
32
|
|
|
27
|
-
add_index(table, column, **
|
|
33
|
+
add_index(table, column, **)
|
|
28
34
|
end
|
|
29
35
|
|
|
30
|
-
def safe_remove_index(table, column_name = nil, **
|
|
31
|
-
index_exists?(table, column_name, **
|
|
36
|
+
def safe_remove_index(table, column_name = nil, **)
|
|
37
|
+
index_exists?(table, column_name, **) && remove_index(table, column_name, **)
|
|
32
38
|
end
|
|
33
39
|
|
|
34
40
|
def safe_add_column_and_index(table, column, type, column_options = {}, index_options = {})
|
|
@@ -41,31 +47,37 @@ module SafeMigrations
|
|
|
41
47
|
safe_remove_column(table, column, **column_options)
|
|
42
48
|
end
|
|
43
49
|
|
|
44
|
-
def safe_change_column(table, column, type, **
|
|
45
|
-
column_exists?(table, column)
|
|
50
|
+
def safe_change_column(table, column, type, **)
|
|
51
|
+
if column_exists?(table, column)
|
|
52
|
+
change_column(table, column, type, **)
|
|
53
|
+
else
|
|
54
|
+
add_column(table, column, type, **)
|
|
55
|
+
end
|
|
46
56
|
end
|
|
47
57
|
|
|
48
58
|
def safe_change_column_null(table, column, null, default = nil)
|
|
49
59
|
column_exists?(table, column) && change_column_null(table, column, null, default)
|
|
50
60
|
end
|
|
51
61
|
|
|
52
|
-
def safe_create_table(table,
|
|
53
|
-
create_table(table,
|
|
62
|
+
def safe_create_table(table, **, &)
|
|
63
|
+
create_table(table, **, &) unless table_exists?(table)
|
|
54
64
|
end
|
|
55
65
|
|
|
56
66
|
def safe_drop_table(table, **)
|
|
57
67
|
drop_table(table, if_exists: true, **) if table_exists?(table)
|
|
58
68
|
end
|
|
59
69
|
|
|
60
|
-
def safe_add_foreign_key(from_table, to_table, **
|
|
70
|
+
def safe_add_foreign_key(from_table, to_table, **)
|
|
61
71
|
return unless table_exists?(from_table) && table_exists?(to_table)
|
|
62
|
-
return if foreign_key_exists?(from_table, to_table, **
|
|
72
|
+
return if foreign_key_exists?(from_table, to_table, **)
|
|
63
73
|
|
|
64
|
-
add_foreign_key(from_table, to_table, **
|
|
74
|
+
add_foreign_key(from_table, to_table, **)
|
|
65
75
|
end
|
|
66
76
|
|
|
67
|
-
def safe_remove_foreign_key(from_table, to_table, **
|
|
68
|
-
table_exists?(from_table) && table_exists?(to_table) &&
|
|
77
|
+
def safe_remove_foreign_key(from_table, to_table, **)
|
|
78
|
+
table_exists?(from_table) && table_exists?(to_table) &&
|
|
79
|
+
foreign_key_exists?(from_table, to_table, **) &&
|
|
80
|
+
remove_foreign_key(from_table, to_table, **)
|
|
69
81
|
end
|
|
70
82
|
|
|
71
83
|
def safe_add_reference(table, ref_name, **)
|
|
@@ -75,7 +87,8 @@ module SafeMigrations
|
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
def safe_remove_reference(table, ref_name, **)
|
|
78
|
-
table_exists?(table) && column_exists?(table, "#{ref_name.to_s.singularize}_id") &&
|
|
90
|
+
table_exists?(table) && column_exists?(table, "#{ref_name.to_s.singularize}_id") &&
|
|
91
|
+
remove_reference(table, ref_name, **)
|
|
79
92
|
end
|
|
80
93
|
|
|
81
94
|
def check_constraint_exists?(table_name, **options)
|
|
@@ -93,7 +106,8 @@ module SafeMigrations
|
|
|
93
106
|
end
|
|
94
107
|
|
|
95
108
|
def safe_remove_check_constraint(table, condition, name:, **)
|
|
96
|
-
table_exists?(table) && check_constraint_exists?(table, name:) &&
|
|
109
|
+
table_exists?(table) && check_constraint_exists?(table, name:) &&
|
|
110
|
+
remove_check_constraint(table, condition, name:, **)
|
|
97
111
|
end
|
|
98
112
|
|
|
99
113
|
def safe_change_column_default(table, column, default_or_changes)
|
data/lib/safe_migrations.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'active_record'
|
|
|
4
4
|
require 'safe_migrations/version'
|
|
5
5
|
require 'safe_migrations/migration_helper'
|
|
6
6
|
require 'safe_migrations/command_recorder_extension'
|
|
7
|
+
require 'safe_migrations/dry_run'
|
|
7
8
|
|
|
8
9
|
module SafeMigrations
|
|
9
10
|
class Error < StandardError; end
|
|
@@ -11,4 +12,5 @@ module SafeMigrations
|
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
ActiveRecord::ConnectionAdapters::AbstractAdapter.include(SafeMigrations::MigrationHelper)
|
|
15
|
+
ActiveRecord::Migration.include(SafeMigrations::DryRun)
|
|
14
16
|
SafeMigrations::CommandRecorderExtension.apply
|
data/safe_migrations.gemspec
CHANGED
|
@@ -11,7 +11,10 @@ Gem::Specification.new do |spec|
|
|
|
11
11
|
spec.email = ['nikolay.moskvin@gmail.com']
|
|
12
12
|
|
|
13
13
|
spec.summary = 'Idempotent migration helpers for Rails with safe, reversible operations.'
|
|
14
|
-
spec.description =
|
|
14
|
+
spec.description = 'SafeMigrations enhances Rails migrations with safe_ prefixed methods that prevent errors ' \
|
|
15
|
+
'by checking for existing schema elements before execution. It integrates with Rails\' ' \
|
|
16
|
+
'CommandRecorder for automatic reversal in change-based migrations, ensuring safe and ' \
|
|
17
|
+
'reliable database schema management.'
|
|
15
18
|
spec.homepage = 'https://github.com/moskvin/safe_migrations'
|
|
16
19
|
|
|
17
20
|
# Prevent pushing this gem to RubyGems.org. To allow pushes either set the 'allowed_push_host'
|
|
@@ -22,9 +25,10 @@ Gem::Specification.new do |spec|
|
|
|
22
25
|
spec.metadata['homepage_uri'] = spec.homepage
|
|
23
26
|
spec.metadata['source_code_uri'] = 'https://github.com/moskvin/safe_migrations'
|
|
24
27
|
spec.metadata['changelog_uri'] = 'https://github.com/moskvin/safe_migrations/blob/master/CHANGELOG.md'
|
|
28
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
25
29
|
else
|
|
26
30
|
raise 'RubyGems 2.0 or newer is required to protect against ' \
|
|
27
|
-
|
|
31
|
+
'public gem pushes.'
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
# Specify which files should be added to the gem when it is released.
|
|
@@ -39,9 +43,4 @@ Gem::Specification.new do |spec|
|
|
|
39
43
|
spec.required_ruby_version = '>= 3.2'
|
|
40
44
|
|
|
41
45
|
spec.add_dependency 'activerecord', '>= 7.0'
|
|
42
|
-
|
|
43
|
-
spec.add_development_dependency 'bundler', '~> 2.7'
|
|
44
|
-
spec.add_development_dependency 'rake', '~> 13.0'
|
|
45
|
-
spec.add_development_dependency 'rspec', '~> 3.0'
|
|
46
|
-
spec.add_development_dependency 'rubocop'
|
|
47
46
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: safe_migrations
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0
|
|
4
|
+
version: 1.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nikolay Moskvin
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: activerecord
|
|
@@ -24,62 +23,6 @@ dependencies:
|
|
|
24
23
|
- - ">="
|
|
25
24
|
- !ruby/object:Gem::Version
|
|
26
25
|
version: '7.0'
|
|
27
|
-
- !ruby/object:Gem::Dependency
|
|
28
|
-
name: bundler
|
|
29
|
-
requirement: !ruby/object:Gem::Requirement
|
|
30
|
-
requirements:
|
|
31
|
-
- - "~>"
|
|
32
|
-
- !ruby/object:Gem::Version
|
|
33
|
-
version: '2.7'
|
|
34
|
-
type: :development
|
|
35
|
-
prerelease: false
|
|
36
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
-
requirements:
|
|
38
|
-
- - "~>"
|
|
39
|
-
- !ruby/object:Gem::Version
|
|
40
|
-
version: '2.7'
|
|
41
|
-
- !ruby/object:Gem::Dependency
|
|
42
|
-
name: rake
|
|
43
|
-
requirement: !ruby/object:Gem::Requirement
|
|
44
|
-
requirements:
|
|
45
|
-
- - "~>"
|
|
46
|
-
- !ruby/object:Gem::Version
|
|
47
|
-
version: '13.0'
|
|
48
|
-
type: :development
|
|
49
|
-
prerelease: false
|
|
50
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
-
requirements:
|
|
52
|
-
- - "~>"
|
|
53
|
-
- !ruby/object:Gem::Version
|
|
54
|
-
version: '13.0'
|
|
55
|
-
- !ruby/object:Gem::Dependency
|
|
56
|
-
name: rspec
|
|
57
|
-
requirement: !ruby/object:Gem::Requirement
|
|
58
|
-
requirements:
|
|
59
|
-
- - "~>"
|
|
60
|
-
- !ruby/object:Gem::Version
|
|
61
|
-
version: '3.0'
|
|
62
|
-
type: :development
|
|
63
|
-
prerelease: false
|
|
64
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
-
requirements:
|
|
66
|
-
- - "~>"
|
|
67
|
-
- !ruby/object:Gem::Version
|
|
68
|
-
version: '3.0'
|
|
69
|
-
- !ruby/object:Gem::Dependency
|
|
70
|
-
name: rubocop
|
|
71
|
-
requirement: !ruby/object:Gem::Requirement
|
|
72
|
-
requirements:
|
|
73
|
-
- - ">="
|
|
74
|
-
- !ruby/object:Gem::Version
|
|
75
|
-
version: '0'
|
|
76
|
-
type: :development
|
|
77
|
-
prerelease: false
|
|
78
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
-
requirements:
|
|
80
|
-
- - ">="
|
|
81
|
-
- !ruby/object:Gem::Version
|
|
82
|
-
version: '0'
|
|
83
26
|
description: SafeMigrations enhances Rails migrations with safe_ prefixed methods
|
|
84
27
|
that prevent errors by checking for existing schema elements before execution. It
|
|
85
28
|
integrates with Rails' CommandRecorder for automatic reversal in change-based migrations,
|
|
@@ -103,9 +46,11 @@ files:
|
|
|
103
46
|
- Rakefile
|
|
104
47
|
- bin/console
|
|
105
48
|
- bin/fix_bundle
|
|
49
|
+
- bin/release
|
|
106
50
|
- bin/setup
|
|
107
51
|
- lib/safe_migrations.rb
|
|
108
52
|
- lib/safe_migrations/command_recorder_extension.rb
|
|
53
|
+
- lib/safe_migrations/dry_run.rb
|
|
109
54
|
- lib/safe_migrations/migration_helper.rb
|
|
110
55
|
- lib/safe_migrations/version.rb
|
|
111
56
|
- logo.jpeg
|
|
@@ -117,7 +62,7 @@ metadata:
|
|
|
117
62
|
homepage_uri: https://github.com/moskvin/safe_migrations
|
|
118
63
|
source_code_uri: https://github.com/moskvin/safe_migrations
|
|
119
64
|
changelog_uri: https://github.com/moskvin/safe_migrations/blob/master/CHANGELOG.md
|
|
120
|
-
|
|
65
|
+
rubygems_mfa_required: 'true'
|
|
121
66
|
rdoc_options: []
|
|
122
67
|
require_paths:
|
|
123
68
|
- lib
|
|
@@ -132,8 +77,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
132
77
|
- !ruby/object:Gem::Version
|
|
133
78
|
version: '0'
|
|
134
79
|
requirements: []
|
|
135
|
-
rubygems_version:
|
|
136
|
-
signing_key:
|
|
80
|
+
rubygems_version: 4.0.16
|
|
137
81
|
specification_version: 4
|
|
138
82
|
summary: Idempotent migration helpers for Rails with safe, reversible operations.
|
|
139
83
|
test_files: []
|