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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: edeb710ced06f1f448d724ce1df93fcb40b537a2f46bde150d6ef2861f2335dd
4
- data.tar.gz: 7d2125344e432d548c9e2389f762fba2a5b187d3d715588483ee75acfcbef838
3
+ metadata.gz: 3ef83718af52c34c752b51c61a5e8fb690fd9530299898c300ac51ba54d17341
4
+ data.tar.gz: 84a966d12c263a36fc8b8a9bce27e2d2589e21f66647c7e743f4480350c4906f
5
5
  SHA512:
6
- metadata.gz: f337746ab082454d1257f59bc5609571ef525959a70013e4cbab71ba1750af3502a1f673c4051cfae50142ef0328310a375cde43a84d3abc57e75fff7a739e49
7
- data.tar.gz: 72a3a411835da83974fa314a1c31f69dace5ac855d06398b8de9675e42537b95db8758ca174eea896faa1bc48eab974b4a55936b718d70d8629bde99e3c820ed
6
+ metadata.gz: 7918fb6c428ec15e024aeb8fabef6a1134f749f544df4a6b53643004ea3b0dec5f54e1bbea5ef7141942aa7542bf4a2aa94c905c22617750ff9201bd6186146a
7
+ data.tar.gz: b2f6952bfb9474d17f2eaa6c9ab544cdb87f959f1f5cab4e59eb736353fff4f513d723e8583c4bc36df2845921a87f80f817385201643fc49b5d667f388d05e5
data/.gitignore CHANGED
@@ -7,5 +7,8 @@
7
7
  /spec/reports/
8
8
  /tmp/
9
9
 
10
+ # macOS Finder metadata
11
+ .DS_Store
12
+
10
13
  # rspec failure tracking
11
14
  .rspec_status
data/.rubocop.yml CHANGED
@@ -1,2 +1,11 @@
1
+ plugins:
2
+ - rubocop-rake
3
+ - rubocop-rspec
4
+
1
5
  AllCops:
2
6
  TargetRubyVersion: 3.2
7
+ NewCops: enable
8
+
9
+ # Migration integration examples verify a complete transaction lifecycle.
10
+ RSpec/ExampleLength:
11
+ Max: 15
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.2.9
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)
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 (5.26.0)
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.6.0)
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.81.6)
67
- json (~> 2.3)
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 (~> 1.10)
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.47.1, < 2.0)
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.47.1)
79
+ rubocop-ast (1.50.0)
78
80
  parser (>= 3.3.7.2)
79
- prism (~> 1.4)
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.1.0)
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, ~> 3.0)
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
- ![safe-pg-migrations](./logo.jpeg)
5
+ ![safe_migrations](./logo.jpeg)
6
6
 
7
7
  [![Gem Version](https://badge.fury.io/rb/safe_migrations.svg)](https://badge.fury.io/rb/safe_migrations)
8
8
  [![CI](https://github.com/moskvin/safe_migrations/actions/workflows/ci.yml/badge.svg)](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
- 1. Update the version in `lib/safe_migrations/version.rb`.
80
- 2. Run `bundle exec rake release` to tag the version, push to Git, and ship the gem to [RubyGems.org](https://rubygems.org).
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.class_eval do
45
- SAFE_METHODS.each do |method|
46
- define_method(:"#{method}") do |*args, &block|
47
- record(:"#{method}", args, &block)
48
- end
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
- SAFE_REVERSIBLE_MAP.each do |cmd, inv|
53
- define_method(:"invert_#{cmd}") do |args, &block|
54
- [inv, args, block]
55
- end
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
- # Special case for safe_rename_column (swap column names)
59
- def invert_safe_rename_column(args)
60
- table, old_col, new_col = args
61
- [:safe_rename_column, [table, new_col, old_col]]
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
- # Special case for safe_change_column_null (needs invert value)
65
- def invert_safe_change_column_null(args)
66
- table, column, value = args
67
- [:safe_change_column_null, [table, column, !value]]
68
- end
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
- def invert_safe_change_column_default(args)
71
- table, column, extra = args
72
- reverted_extra = { to: extra[:from], from: extra[:to] }
73
- [:safe_change_column_default, [table, column, reverted_extra]]
74
- end
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, **options)
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, **options)
16
+ add_column(table, column, type, **)
13
17
  end
14
18
 
15
- def safe_remove_column(table, column, type = nil, **options)
16
- table_exists?(table) && column_exists?(table, column) && remove_column(table, column, type, **options)
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) && !column_exists?(table_name, new_column_name) && rename_column(table_name, column_name, new_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, **options)
29
+ def safe_add_index(table, column, **)
24
30
  return unless table_exists?(table)
25
- return if index_exists?(table, column, **options)
31
+ return if index_exists?(table, column, **)
26
32
 
27
- add_index(table, column, **options)
33
+ add_index(table, column, **)
28
34
  end
29
35
 
30
- def safe_remove_index(table, column_name = nil, **options)
31
- index_exists?(table, column_name, **options) && remove_index(table, column_name, **options)
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, **options)
45
- column_exists?(table, column) ? change_column(table, column, type, **options) : add_column(table, column, type, **options)
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, **options, &block)
53
- create_table(table, **options, &block) unless table_exists?(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, **options)
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, **options)
72
+ return if foreign_key_exists?(from_table, to_table, **)
63
73
 
64
- add_foreign_key(from_table, to_table, **options)
74
+ add_foreign_key(from_table, to_table, **)
65
75
  end
66
76
 
67
- def safe_remove_foreign_key(from_table, to_table, **options)
68
- table_exists?(from_table) && table_exists?(to_table) && foreign_key_exists?(from_table, to_table, **options) && remove_foreign_key(from_table, to_table, **options)
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") && remove_reference(table, ref_name, **)
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:) && remove_check_constraint(table, condition, 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)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module SafeMigrations
2
- VERSION = '1.0.4'.freeze
4
+ VERSION = '1.1.0'
3
5
  end
@@ -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
@@ -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 = "SafeMigrations enhances Rails migrations with safe_ prefixed methods that prevent errors by checking for existing schema elements before execution. It integrates with Rails' CommandRecorder for automatic reversal in change-based migrations, ensuring safe and reliable database schema management."
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
- 'public gem pushes.'
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
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: 2025-11-03 00:00:00.000000000 Z
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
- post_install_message:
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: 3.4.19
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: []