actual_db_schema 0.7.9 → 0.8.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5a0d117f27b4bd908cbc04b0d3ca0faf0297a5feed5fd4f869c5672a50fc3cd9
4
- data.tar.gz: cf0f37bc743eb44c8c5f6350454cc7f84f1781c8a8bc06ef25eb878830d7b973
3
+ metadata.gz: 9197558d7b71582d339535933b2186c9ce3db7d0c4dd492759db5a226931ddf8
4
+ data.tar.gz: ccd05c4164478a54130c6bee32a234abbb2f193292438b8d35b6d7edd313802c
5
5
  SHA512:
6
- metadata.gz: 50f4a4cd52039cd29d308676028a51fd7f44e25b89084e03c2ca570361c3c556f13bc924989415d04164967b63aadf0d88709c0f58289947fd2cf6a164f7f6dc
7
- data.tar.gz: bd471eb79d28d9cf124452fe6e5608eb2a092800b4c49f1299348adf7a7c2a9d6a9bcd85da3f0cc6272753f743862e84b9777bab0cf095def8b9d1180a910e49
6
+ metadata.gz: b12528875d4b7d5b12739b1a37877d979bccc6d66f7a0006454ae260b1215eb69bb5baa2aae332e68bc4f1fa29859e4b00a427591cf78acf2aebc57264889a46
7
+ data.tar.gz: ae2baeaae67451740cc6773abbc79e4d0896baf96b99e7c2baebe027c1b7b05bbbbb75da5717be47c43d240835810a1681408e79c3d44449f7f44c3d4e8830a6
data/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## [0.8.1] - 2025-01-15
2
+
3
+ - Support for multiple database schemas, ensuring compatibility with multi-tenant applications using the apartment gem or similar solutions
4
+ - DSL for configuring the gem, simplifying setup and customization
5
+ - Rake task added to initialize the gem
6
+ - Improved the post-checkout git hook to run only when switching branches, reducing unnecessary executions during file checkouts
7
+ - Fixed the changelog link in the gemspec, ensuring Rubygems points to the correct file and the link works
8
+
9
+ ## [0.8.0] - 2024-12-30
10
+ - Enhanced Console Visibility: Automatically rolled-back phantom migrations now provide clearer and more visible logs in the console
11
+ - Git Hooks for Branch Management: Introduced hooks that automatically rollback phantom migrations after checking out a branch. Additionally, the schema migration rake task can now be executed automatically upon branch checkout
12
+ - Temporary Folder Cleanup: Rolled-back phantom migrations are now automatically deleted from the temporary folder after rollback
13
+ - Acronym Support in Phantom Migration Names: Resolved an issue where phantom migrations with acronyms in their names, defined in other branches, couldn't be rolled back automatically. These are now handled seamlessly
14
+
1
15
  ## [0.7.9] - 2024-09-07
2
16
  - Don't stop if a phantom migration rollback fails
3
17
  - Improve failed rollback of phantom migrations report
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- actual_db_schema (0.7.9)
4
+ actual_db_schema (0.8.1)
5
5
  activerecord
6
6
  activesupport
7
7
  csv
@@ -93,7 +93,7 @@ GEM
93
93
  concurrent-ruby (1.2.2)
94
94
  connection_pool (2.4.1)
95
95
  crass (1.0.6)
96
- csv (3.3.0)
96
+ csv (3.3.2)
97
97
  date (3.3.3)
98
98
  debug (1.8.0)
99
99
  irb (>= 1.5.0)
@@ -222,6 +222,7 @@ GEM
222
222
  zeitwerk (2.6.12)
223
223
 
224
224
  PLATFORMS
225
+ arm64-darwin-22
225
226
  arm64-darwin-23
226
227
  x86_64-darwin-20
227
228
  x86_64-darwin-22
data/README.md CHANGED
@@ -50,6 +50,16 @@ And then execute:
50
50
 
51
51
  If you cannot commit changes to the repo or Gemfile, consider the local Gemfile installation described in [this post](https://blog.widefix.com/personal-gemfile-for-development/).
52
52
 
53
+ Next, generate your ActualDbSchema initializer file by running:
54
+
55
+ ```sh
56
+ rake actual_db_schema:install
57
+ ```
58
+
59
+ This will create a `config/initializers/actual_db_schema.rb` file with all the available configuration options so you can adjust them as needed. It will also prompt you to install the post-checkout Git hook for automatic phantom migration rollback when switching branches.
60
+
61
+ For more details on the available configuration options, see the sections below.
62
+
53
63
  ## Usage
54
64
 
55
65
  Just run `rails db:migrate` inside the current branch. It will roll back all phantom migrations for all configured databases in your `database.yml.`
@@ -86,7 +96,7 @@ export ACTUAL_DB_SCHEMA_UI_ENABLED=true
86
96
  Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`):
87
97
 
88
98
  ```ruby
89
- ActualDbSchema.config[:ui_enabled] = true
99
+ config.ui_enabled = true
90
100
  ```
91
101
 
92
102
  > With this option, the UI can be disabled for all environments or be enabled in specific ones.
@@ -107,7 +117,55 @@ export ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED=true
107
117
  Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`):
108
118
 
109
119
  ```ruby
110
- ActualDbSchema.config[:auto_rollback_disabled] = true
120
+ config.auto_rollback_disabled = true
121
+ ```
122
+
123
+ ## Automatic Phantom Migration Rollback On Branch Switch
124
+
125
+ By default, the automatic rollback of migrations on branch switch is disabled. If you prefer to automatically rollback phantom migrations whenever you switch branches with `git checkout`, you can enable it in two ways:
126
+
127
+ ### 1. Using Environment Variable
128
+
129
+ Set the environment variable `ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED` to `true`:
130
+
131
+ ```sh
132
+ export ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED=true
133
+ ```
134
+
135
+ ### 2. Using Initializer
136
+ Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`):
137
+
138
+ ```ruby
139
+ config.git_hooks_enabled = true
140
+ ```
141
+
142
+ ### Installing the Post-Checkout Hook
143
+ After enabling Git hooks in your configuration, run the rake task to install the post-checkout hook:
144
+
145
+ ```sh
146
+ rake actual_db_schema:install_git_hooks
147
+ ```
148
+
149
+ This task will prompt you to choose one of the three options:
150
+
151
+ 1. Rollback phantom migrations with `db:rollback_branches`
152
+ 2. Migrate up to the latest schema with `db:migrate`
153
+ 3. Skip installing git hook
154
+
155
+ Based on your selection, a post-checkout hook will be installed or updated in your `.git/hooks` folder.
156
+
157
+ ## Multi-Tenancy Support
158
+
159
+ If your application leverages multiple schemas for multi-tenancy — such as those implemented by the [apartment](https://github.com/influitive/apartment) gem or similar solutions — you can configure ActualDbSchema to handle migrations across all schemas. To do so, add the following configuration to your initializer file (`config/initializers/actual_db_schema.rb`):
160
+
161
+ ```ruby
162
+ config.multi_tenant_schemas = -> { # list of all active schemas }
163
+ ```
164
+
165
+ ### Example:
166
+
167
+ ```ruby
168
+ config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
111
169
  ```
112
170
 
113
171
  ## Development
@@ -148,6 +206,24 @@ To run tests with a specific version of Rails using Appraisal:
148
206
  bundle exec appraisal rails.6.0 rake test TEST=test/rake_task_test.rb TESTOPTS="--name=/db::db:rollback_branches#test_0003_keeps/"
149
207
  ```
150
208
 
209
+ By default, `rake test` runs tests using `SQLite3`. To explicitly run tests with `SQLite3`, `PostgreSQL`, or `MySQL`, you can use the following tasks:
210
+ - Run tests with `SQLite3`:
211
+ ```sh
212
+ bundle exec rake test:sqlite3
213
+ ```
214
+ - Run tests with `PostgreSQL` (requires Docker):
215
+ ```sh
216
+ bundle exec rake test:postgresql
217
+ ```
218
+ - Run tests with `MySQL` (requires Docker):
219
+ ```sh
220
+ bundle exec rake test:mysql2
221
+ ```
222
+ - Run tests for all supported adapters:
223
+ ```sh
224
+ bundle exec rake test:all
225
+ ```
226
+
151
227
  ## Contributing
152
228
 
153
229
  Bug reports and pull requests are welcome on GitHub at https://github.com/widefix/actual_db_schema. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/widefix/actual_db_schema/blob/master/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -3,6 +3,8 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rake/testtask"
5
5
 
6
+ load "lib/tasks/test.rake"
7
+
6
8
  Rake::TestTask.new(:test) do |t|
7
9
  t.libs << "test"
8
10
  t.libs << "lib"
@@ -21,7 +21,7 @@ Gem::Specification.new do |spec|
21
21
 
22
22
  spec.metadata["homepage_uri"] = spec.homepage
23
23
  spec.metadata["source_code_uri"] = "https://github.com/widefix/actual_db_schema"
24
- spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
24
+ spec.metadata["changelog_uri"] = "https://github.com/widefix/actual_db_schema/blob/main/CHANGELOG.md"
25
25
 
26
26
  # Specify which files should be added to the gem when it is released.
27
27
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
@@ -44,6 +44,18 @@ Gem::Specification.new do |spec|
44
44
  spec.add_development_dependency "rails"
45
45
  spec.add_development_dependency "sqlite3"
46
46
 
47
+ spec.post_install_message = <<~MSG
48
+ Thank you for installing ActualDbSchema!
49
+
50
+ Next steps:
51
+ 1. Run `rake actual_db_schema:install` to generate the initializer file and install
52
+ the post-checkout Git hook for automatic phantom migration rollback when switching branches.
53
+ 2. Or, if you prefer environment variables, skip this step.
54
+
55
+ For more information, see the README.
56
+
57
+ MSG
58
+
47
59
  # For more information and examples about making a new gem, check out our
48
60
  # guide at: https://bundler.io/guides/creating_gem.html
49
61
  end
@@ -0,0 +1 @@
1
+ CREATE DATABASE actual_db_schema_test_secondary;
@@ -0,0 +1 @@
1
+ CREATE DATABASE actual_db_schema_test_secondary;
@@ -0,0 +1,23 @@
1
+ version: '3.8'
2
+
3
+ services:
4
+ postgres:
5
+ image: postgres:14
6
+ environment:
7
+ POSTGRES_USER: postgres
8
+ POSTGRES_PASSWORD: password
9
+ POSTGRES_DB: actual_db_schema_test
10
+ ports:
11
+ - "5432:5432"
12
+ volumes:
13
+ - ./docker/postgres-init:/docker-entrypoint-initdb.d
14
+
15
+ mysql:
16
+ image: mysql:8.0
17
+ environment:
18
+ MYSQL_ROOT_PASSWORD: password
19
+ MYSQL_DATABASE: actual_db_schema_test
20
+ ports:
21
+ - "3306:3306"
22
+ volumes:
23
+ - ./docker/mysql-init:/docker-entrypoint-initdb.d
@@ -7,6 +7,8 @@ source "https://rubygems.org"
7
7
  gem "activerecord", "~> 6.0.0"
8
8
  gem "activesupport", "~> 6.0.0"
9
9
  gem "minitest", "~> 5.0"
10
+ gem "mysql2", "~> 0.5.2"
11
+ gem "pg", "~> 1.5"
10
12
  gem "rake"
11
13
  gem "rubocop", "~> 1.21"
12
14
  gem "sqlite3", "~> 1.4.0"
@@ -7,6 +7,8 @@ source "https://rubygems.org"
7
7
  gem "activerecord", "~> 6.1.0"
8
8
  gem "activesupport", "~> 6.1.0"
9
9
  gem "minitest", "~> 5.0"
10
+ gem "mysql2", "~> 0.5.2"
11
+ gem "pg", "~> 1.5"
10
12
  gem "rake"
11
13
  gem "rubocop", "~> 1.21"
12
14
  gem "sqlite3", "~> 1.4.0"
@@ -7,6 +7,8 @@ source "https://rubygems.org"
7
7
  gem "activerecord", "~> 7.0.0"
8
8
  gem "activesupport", "~> 7.0.0"
9
9
  gem "minitest", "~> 5.0"
10
+ gem "mysql2", "~> 0.5.2"
11
+ gem "pg", "~> 1.5"
10
12
  gem "rake"
11
13
  gem "rubocop", "~> 1.21"
12
14
  gem "sqlite3", "~> 1.4.0"
@@ -7,6 +7,8 @@ source "https://rubygems.org"
7
7
  gem "activerecord", "~> 7.1.0"
8
8
  gem "activesupport", "~> 7.1.0"
9
9
  gem "minitest", "~> 5.0"
10
+ gem "mysql2", "~> 0.5.2"
11
+ gem "pg", "~> 1.5"
10
12
  gem "rake"
11
13
  gem "rubocop", "~> 1.21"
12
14
  gem "sqlite3", "~> 1.4.0"
@@ -7,6 +7,8 @@ source "https://rubygems.org"
7
7
  gem "activerecord", ">= 7.2.0.beta"
8
8
  gem "activesupport", ">= 7.2.0.beta"
9
9
  gem "minitest", "~> 5.0"
10
+ gem "mysql2", "~> 0.5.2"
11
+ gem "pg", "~> 1.5"
10
12
  gem "rake"
11
13
  gem "rubocop", "~> 1.21"
12
14
  gem "rails", ">= 7.2.0.beta"
@@ -4,11 +4,8 @@ module ActualDbSchema
4
4
  module Commands
5
5
  # Rolls back all phantom migrations
6
6
  class Rollback < Base
7
- UNICODE_COLORS = {
8
- red: 31,
9
- green: 32,
10
- yellow: 33
11
- }.freeze
7
+ include ActualDbSchema::OutputFormatter
8
+ include ActionView::Helpers::TextHelper
12
9
 
13
10
  def initialize(context, manual_mode: false)
14
11
  @manual_mode = manual_mode || manual_mode_default?
@@ -18,50 +15,62 @@ module ActualDbSchema
18
15
  private
19
16
 
20
17
  def call_impl
21
- context.rollback_branches(manual_mode: @manual_mode)
18
+ rolled_back = context.rollback_branches(manual_mode: @manual_mode)
22
19
 
23
- return if ActualDbSchema.failed.empty?
20
+ return unless rolled_back || ActualDbSchema.failed.any?
24
21
 
25
- puts_preamble
26
- puts_into
27
- puts ""
28
- puts failed_migrations_list
29
- puts_preamble
22
+ ActualDbSchema.failed.empty? ? print_success : print_error
23
+ end
24
+
25
+ def print_success
26
+ puts colorize("[ActualDbSchema] All phantom migrations rolled back successfully! 🎉", :green)
27
+ end
28
+
29
+ def print_error
30
+ header_message = <<~HEADER
31
+ #{ActualDbSchema.failed.count} phantom migration(s) could not be rolled back automatically.
32
+
33
+ Try these steps to fix and move forward:
34
+ 1. Ensure the migrations are reversible (define #up and #down methods or use #reversible).
35
+ 2. If the migration references code or tables from another branch, restore or remove them.
36
+ 3. Once fixed, run `rails db:migrate` again.
37
+
38
+ Below are the details of the problematic migrations:
39
+ HEADER
40
+
41
+ print_error_summary("#{header_message}\n#{failed_migrations_list}")
30
42
  end
31
43
 
32
44
  def failed_migrations_list
33
45
  ActualDbSchema.failed.map.with_index(1) do |failed, index|
34
- filename = failed.short_filename
35
- exception = failed.exception
36
- <<~MSG
37
- \t#{colorize("[Migration##{index}]", :yellow)}
38
- \t- #{filename}
39
-
40
- \t\t#{exception.inspect.gsub("\n", "\n\t ")}
41
- MSG
42
- end
46
+ migration_details = colorize("Migration ##{index}:\n", :yellow)
47
+ migration_details += " File: #{failed.short_filename}\n"
48
+ migration_details += " Schema: #{failed.schema}\n" if failed.schema
49
+ migration_details + " Branch: #{failed.branch}\n"
50
+ end.join("\n")
43
51
  end
44
52
 
45
- def puts_preamble
46
- puts ""
47
- puts %(\u2757\u2757\u2757 #{colorize("[ActualDbSchema]", :red)})
48
- puts ""
53
+ def print_error_summary(content)
54
+ width = 100
55
+ indent = 4
56
+ gem_name = "ActualDbSchema"
57
+
58
+ puts colorize("╔═ [#{gem_name}] #{"═" * (width - gem_name.length - 5)}╗", :red)
59
+ print_wrapped_content(content, width, indent)
60
+ puts colorize("╚#{"═" * width}╝", :red)
49
61
  end
50
62
 
51
- def puts_into
52
- msg = "#{ActualDbSchema.failed.count} phantom migration(s) could not be rolled back automatically."
53
- msg += " Roll them back or fix manually:"
54
- puts colorize(msg, :red)
63
+ def print_wrapped_content(content, width, indent)
64
+ usable_width = width - indent - 4
65
+ wrapped_content = word_wrap(content, line_width: usable_width)
66
+ wrapped_content.each_line do |line|
67
+ puts "#{" " * indent}#{line.chomp}"
68
+ end
55
69
  end
56
70
 
57
71
  def manual_mode_default?
58
72
  ActualDbSchema.config[:auto_rollback_disabled]
59
73
  end
60
-
61
- def colorize(text, color)
62
- code = UNICODE_COLORS.fetch(color, 37)
63
- "\e[#{code}m#{text}\e[0m"
64
- end
65
74
  end
66
75
  end
67
76
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ # Manages the configuration settings for the gem.
5
+ class Configuration
6
+ attr_accessor :enabled, :auto_rollback_disabled, :ui_enabled, :git_hooks_enabled, :multi_tenant_schemas
7
+
8
+ def initialize
9
+ @enabled = Rails.env.development?
10
+ @auto_rollback_disabled = ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?
11
+ @ui_enabled = Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
12
+ @git_hooks_enabled = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?
13
+ @multi_tenant_schemas = nil
14
+ end
15
+
16
+ def [](key)
17
+ public_send(key)
18
+ end
19
+
20
+ def []=(key, value)
21
+ public_send("#{key}=", value)
22
+ end
23
+
24
+ def fetch(key, default = nil)
25
+ if respond_to?(key)
26
+ public_send(key)
27
+ else
28
+ default
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActualDbSchema
4
- FailedMigration = Struct.new(:migration, :exception, keyword_init: true) do
4
+ FailedMigration = Struct.new(:migration, :exception, :branch, :schema, keyword_init: true) do
5
5
  def filename
6
6
  migration.filename
7
7
  end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module ActualDbSchema
6
+ # Handles the installation of a git post-checkout hook that rolls back phantom migrations when switching branches
7
+ class GitHooks # rubocop:disable Metrics/ClassLength
8
+ include ActualDbSchema::OutputFormatter
9
+
10
+ POST_CHECKOUT_MARKER_START = "# >>> BEGIN ACTUAL_DB_SCHEMA"
11
+ POST_CHECKOUT_MARKER_END = "# <<< END ACTUAL_DB_SCHEMA"
12
+
13
+ POST_CHECKOUT_HOOK_ROLLBACK = <<~BASH
14
+ #{POST_CHECKOUT_MARKER_START}
15
+ # ActualDbSchema post-checkout hook (ROLLBACK)
16
+ # Runs db:rollback_branches on branch checkout.
17
+
18
+ # Check if this is a file checkout or creating a new branch
19
+ if [ "$3" == "0" ] || [ "$1" == "$2" ]; then
20
+ exit 0
21
+ fi
22
+
23
+ if [ -f ./bin/rails ]; then
24
+ if [ -n "$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED" ]; then
25
+ GIT_HOOKS_ENABLED="$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"
26
+ else
27
+ GIT_HOOKS_ENABLED=$(./bin/rails runner "puts ActualDbSchema.config[:git_hooks_enabled]" 2>/dev/null)
28
+ fi
29
+
30
+ if [ "$GIT_HOOKS_ENABLED" == "true" ]; then
31
+ ./bin/rails db:rollback_branches
32
+ fi
33
+ fi
34
+ #{POST_CHECKOUT_MARKER_END}
35
+ BASH
36
+
37
+ POST_CHECKOUT_HOOK_MIGRATE = <<~BASH
38
+ #{POST_CHECKOUT_MARKER_START}
39
+ # ActualDbSchema post-checkout hook (MIGRATE)
40
+ # Runs db:migrate on branch checkout.
41
+
42
+ # Check if this is a file checkout or creating a new branch
43
+ if [ "$3" == "0" ] || [ "$1" == "$2" ]; then
44
+ exit 0
45
+ fi
46
+
47
+ if [ -f ./bin/rails ]; then
48
+ if [ -n "$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED" ]; then
49
+ GIT_HOOKS_ENABLED="$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"
50
+ else
51
+ GIT_HOOKS_ENABLED=$(./bin/rails runner "puts ActualDbSchema.config[:git_hooks_enabled]" 2>/dev/null)
52
+ fi
53
+
54
+ if [ "$GIT_HOOKS_ENABLED" == "true" ]; then
55
+ ./bin/rails db:migrate
56
+ fi
57
+ fi
58
+ #{POST_CHECKOUT_MARKER_END}
59
+ BASH
60
+
61
+ def initialize(strategy: :rollback)
62
+ @strategy = strategy
63
+ end
64
+
65
+ def install_post_checkout_hook
66
+ return unless git_hooks_enabled?
67
+ return unless hooks_directory_present?
68
+
69
+ if File.exist?(hook_path)
70
+ handle_existing_hook
71
+ else
72
+ create_new_hook
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def hook_code
79
+ @strategy == :migrate ? POST_CHECKOUT_HOOK_MIGRATE : POST_CHECKOUT_HOOK_ROLLBACK
80
+ end
81
+
82
+ def hooks_dir
83
+ @hooks_dir ||= Rails.root.join(".git", "hooks")
84
+ end
85
+
86
+ def hook_path
87
+ @hook_path ||= hooks_dir.join("post-checkout")
88
+ end
89
+
90
+ def git_hooks_enabled?
91
+ return true if ActualDbSchema.config[:git_hooks_enabled]
92
+
93
+ puts colorize("[ActualDbSchema] Git hooks are disabled in configuration. Skipping installation.", :gray)
94
+ end
95
+
96
+ def hooks_directory_present?
97
+ return true if Dir.exist?(hooks_dir)
98
+
99
+ puts colorize("[ActualDbSchema] .git/hooks directory not found. Please ensure this is a Git repository.", :gray)
100
+ end
101
+
102
+ def handle_existing_hook
103
+ return update_hook if markers_exist?
104
+ return install_hook if safe_install?
105
+
106
+ show_manual_install_instructions
107
+ end
108
+
109
+ def create_new_hook
110
+ contents = <<~BASH
111
+ #!/usr/bin/env bash
112
+
113
+ #{hook_code}
114
+ BASH
115
+
116
+ write_hook_file(contents)
117
+ print_success
118
+ end
119
+
120
+ def markers_exist?
121
+ contents = File.read(hook_path)
122
+ contents.include?(POST_CHECKOUT_MARKER_START) && contents.include?(POST_CHECKOUT_MARKER_END)
123
+ end
124
+
125
+ def update_hook
126
+ contents = File.read(hook_path)
127
+ new_contents = replace_marker_contents(contents)
128
+
129
+ if new_contents == contents
130
+ message = "[ActualDbSchema] post-checkout git hook already contains the necessary code. Nothing to update."
131
+ puts colorize(message, :gray)
132
+ else
133
+ write_hook_file(new_contents)
134
+ puts colorize("[ActualDbSchema] post-checkout git hook updated successfully at #{hook_path}", :green)
135
+ end
136
+ end
137
+
138
+ def replace_marker_contents(contents)
139
+ contents.gsub(
140
+ /#{Regexp.quote(POST_CHECKOUT_MARKER_START)}.*#{Regexp.quote(POST_CHECKOUT_MARKER_END)}/m,
141
+ hook_code.strip
142
+ )
143
+ end
144
+
145
+ def safe_install?
146
+ puts colorize("[ActualDbSchema] A post-checkout hook already exists at #{hook_path}.", :gray)
147
+ puts "Overwrite the existing hook at #{hook_path}? [y,n] "
148
+
149
+ answer = $stdin.gets.chomp.downcase
150
+ answer.start_with?("y")
151
+ end
152
+
153
+ def install_hook
154
+ contents = File.read(hook_path)
155
+ new_contents = <<~BASH
156
+ #{contents.rstrip}
157
+
158
+ #{hook_code}
159
+ BASH
160
+
161
+ write_hook_file(new_contents)
162
+ print_success
163
+ end
164
+
165
+ def show_manual_install_instructions
166
+ puts colorize("[ActualDbSchema] You can follow these steps to manually install the hook:", :yellow)
167
+ puts <<~MSG
168
+
169
+ 1. Open the existing post-checkout hook at:
170
+ #{hook_path}
171
+
172
+ 2. Insert the following lines into that file (preferably at the end or in a relevant section).
173
+ Make sure you include the #{POST_CHECKOUT_MARKER_START} and #{POST_CHECKOUT_MARKER_END} lines:
174
+
175
+ #{hook_code}
176
+
177
+ 3. Ensure the post-checkout file is executable:
178
+ chmod +x #{hook_path}
179
+
180
+ 4. Done! Now when you switch branches, phantom migrations will be rolled back automatically (if enabled).
181
+
182
+ MSG
183
+ end
184
+
185
+ def write_hook_file(contents)
186
+ File.open(hook_path, "w") { |file| file.write(contents) }
187
+ FileUtils.chmod("+x", hook_path)
188
+ end
189
+
190
+ def print_success
191
+ puts colorize("[ActualDbSchema] post-checkout git hook installed successfully at #{hook_path}", :green)
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ # Handles multi-tenancy support by switching schemas for supported databases
5
+ module MultiTenant
6
+ include ActualDbSchema::OutputFormatter
7
+
8
+ class << self
9
+ def with_schema(schema_name)
10
+ context = switch_schema(schema_name)
11
+ yield
12
+ ensure
13
+ restore_context(context)
14
+ end
15
+
16
+ private
17
+
18
+ def adapter_name
19
+ ActiveRecord::Base.connection.adapter_name
20
+ end
21
+
22
+ def switch_schema(schema_name)
23
+ case adapter_name
24
+ when /postgresql/i
25
+ switch_postgresql_schema(schema_name)
26
+ when /mysql/i
27
+ switch_mysql_schema(schema_name)
28
+ else
29
+ message = "[ActualDbSchema] Multi-tenancy not supported for adapter: #{adapter_name}. " \
30
+ "Proceeding without schema switching."
31
+ puts colorize(message, :gray)
32
+ end
33
+ end
34
+
35
+ def switch_postgresql_schema(schema_name)
36
+ old_search_path = ActiveRecord::Base.connection.schema_search_path
37
+ ActiveRecord::Base.connection.schema_search_path = schema_name
38
+ { type: :postgresql, old_context: old_search_path }
39
+ end
40
+
41
+ def switch_mysql_schema(schema_name)
42
+ old_db = ActiveRecord::Base.connection.current_database
43
+ ActiveRecord::Base.connection.execute("USE #{ActiveRecord::Base.connection.quote_table_name(schema_name)}")
44
+ { type: :mysql, old_context: old_db }
45
+ end
46
+
47
+ def restore_context(context)
48
+ return unless context
49
+
50
+ case context[:type]
51
+ when :postgresql
52
+ ActiveRecord::Base.connection.schema_search_path = context[:old_context] if context[:old_context]
53
+ when :mysql
54
+ return unless context[:old_context]
55
+
56
+ ActiveRecord::Base.connection.execute(
57
+ "USE #{ActiveRecord::Base.connection.quote_table_name(context[:old_context])}"
58
+ )
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActualDbSchema
4
+ # Provides functionality for formatting terminal output with colors
5
+ module OutputFormatter
6
+ UNICODE_COLORS = {
7
+ red: 31,
8
+ green: 32,
9
+ yellow: 33,
10
+ gray: 90
11
+ }.freeze
12
+
13
+ def colorize(text, color)
14
+ code = UNICODE_COLORS.fetch(color, 37)
15
+ "\e[#{code}m#{text}\e[0m"
16
+ end
17
+ end
18
+ end
@@ -3,16 +3,21 @@
3
3
  module ActualDbSchema
4
4
  module Patches
5
5
  # Add new command to roll back the phantom migrations
6
- module MigrationContext
6
+ module MigrationContext # rubocop:disable Metrics/ModuleLength
7
+ include ActualDbSchema::OutputFormatter
8
+
7
9
  def rollback_branches(manual_mode: false)
8
- phantom_migrations.reverse_each do |migration|
9
- next unless status_up?(migration)
10
+ schemas = multi_tenant_schemas&.call || []
11
+ schema_count = schemas.any? ? schemas.size : 1
10
12
 
11
- show_info_for(migration) if manual_mode
12
- migrate(migration) if !manual_mode || user_wants_rollback?
13
- rescue StandardError => e
14
- ActualDbSchema.failed << FailedMigration.new(migration: migration, exception: e)
15
- end
13
+ rolled_back_migrations = if schemas.any?
14
+ rollback_multi_tenant(schemas, manual_mode: manual_mode)
15
+ else
16
+ rollback_branches_for_schema(manual_mode: manual_mode)
17
+ end
18
+
19
+ delete_migrations(rolled_back_migrations, schema_count)
20
+ rolled_back_migrations.any?
16
21
  end
17
22
 
18
23
  def phantom_migrations
@@ -27,6 +32,32 @@ module ActualDbSchema
27
32
 
28
33
  private
29
34
 
35
+ def rollback_branches_for_schema(manual_mode: false, schema_name: nil, rolled_back_migrations: [])
36
+ phantom_migrations.reverse_each do |migration|
37
+ next unless status_up?(migration)
38
+
39
+ show_info_for(migration, schema_name) if manual_mode
40
+ migrate(migration, rolled_back_migrations, schema_name) if !manual_mode || user_wants_rollback?
41
+ rescue StandardError => e
42
+ handle_rollback_error(migration, e, schema_name)
43
+ end
44
+
45
+ rolled_back_migrations
46
+ end
47
+
48
+ def rollback_multi_tenant(schemas, manual_mode: false)
49
+ all_rolled_back_migrations = []
50
+
51
+ schemas.each do |schema_name|
52
+ ActualDbSchema::MultiTenant.with_schema(schema_name) do
53
+ rollback_branches_for_schema(manual_mode: manual_mode, schema_name: schema_name,
54
+ rolled_back_migrations: all_rolled_back_migrations)
55
+ end
56
+ end
57
+
58
+ all_rolled_back_migrations
59
+ end
60
+
30
61
  def down_migrator_for(migration)
31
62
  if ActiveRecord::Migration.current_version < 6
32
63
  ActiveRecord::Migrator.new(:down, [migration], migration.version)
@@ -62,19 +93,34 @@ module ActualDbSchema
62
93
  answer[0] == "y"
63
94
  end
64
95
 
65
- def show_info_for(migration)
66
- puts "\n[ActualDbSchema] A phantom migration was found and is about to be rolled back."
96
+ def show_info_for(migration, schema_name = nil)
97
+ puts colorize("\n[ActualDbSchema] A phantom migration was found and is about to be rolled back.", :gray)
67
98
  puts "Please make a decision from the options below to proceed.\n\n"
99
+ puts "Schema: #{schema_name}" if schema_name
68
100
  puts "Branch: #{branch_for(migration.version.to_s)}"
69
101
  puts "Database: #{ActualDbSchema.db_config[:database]}"
70
102
  puts "Version: #{migration.version}\n\n"
71
103
  puts File.read(migration.filename)
72
104
  end
73
105
 
74
- def migrate(migration)
106
+ def migrate(migration, rolled_back_migrations, schema_name = nil)
107
+ migration.name = extract_class_name(migration.filename)
108
+
109
+ message = "[ActualDbSchema]"
110
+ message += " #{schema_name}:" if schema_name
111
+ message += " Rolling back phantom migration #{migration.version} #{migration.name} " \
112
+ "(from branch: #{branch_for(migration.version.to_s)})"
113
+ puts colorize(message, :gray)
114
+
75
115
  migrator = down_migrator_for(migration)
76
116
  migrator.extend(ActualDbSchema::Patches::Migrator)
77
117
  migrator.migrate
118
+ rolled_back_migrations << migration
119
+ end
120
+
121
+ def extract_class_name(filename)
122
+ content = File.read(filename)
123
+ content.match(/^class\s+([A-Za-z0-9_]+)\s+</)[1]
78
124
  end
79
125
 
80
126
  def branch_for(version)
@@ -84,6 +130,46 @@ module ActualDbSchema
84
130
  def metadata
85
131
  @metadata ||= ActualDbSchema::Store.instance.read
86
132
  end
133
+
134
+ def handle_rollback_error(migration, exception, schema_name = nil)
135
+ error_message = <<~ERROR
136
+ Error encountered during rollback:
137
+
138
+ #{cleaned_exception_message(exception.message)}
139
+ ERROR
140
+
141
+ puts colorize(error_message, :red)
142
+ ActualDbSchema.failed << FailedMigration.new(
143
+ migration: migration,
144
+ exception: exception,
145
+ branch: branch_for(migration.version.to_s),
146
+ schema: schema_name
147
+ )
148
+ end
149
+
150
+ def cleaned_exception_message(message)
151
+ patterns_to_remove = [
152
+ /^An error has occurred, all later migrations canceled:\s*/,
153
+ /^An error has occurred, this and all later migrations canceled:\s*/
154
+ ]
155
+
156
+ patterns_to_remove.reduce(message.strip) { |msg, pattern| msg.gsub(pattern, "").strip }
157
+ end
158
+
159
+ def delete_migrations(migrations, schema_count)
160
+ migration_counts = migrations.each_with_object(Hash.new(0)) do |migration, hash|
161
+ hash[migration.filename] += 1
162
+ end
163
+
164
+ migrations.uniq.each do |migration|
165
+ count = migration_counts[migration.filename]
166
+ File.delete(migration.filename) if count == schema_count && File.exist?(migration.filename)
167
+ end
168
+ end
169
+
170
+ def multi_tenant_schemas
171
+ ActualDbSchema.config[:multi_tenant_schemas]
172
+ end
87
173
  end
88
174
  end
89
175
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActualDbSchema
4
- VERSION = "0.7.9"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -4,14 +4,18 @@ require "actual_db_schema/engine"
4
4
  require "active_record/migration"
5
5
  require "csv"
6
6
  require_relative "actual_db_schema/git"
7
+ require_relative "actual_db_schema/configuration"
7
8
  require_relative "actual_db_schema/store"
8
9
  require_relative "actual_db_schema/version"
9
10
  require_relative "actual_db_schema/migration"
10
11
  require_relative "actual_db_schema/failed_migration"
11
12
  require_relative "actual_db_schema/migration_context"
13
+ require_relative "actual_db_schema/output_formatter"
12
14
  require_relative "actual_db_schema/patches/migration_proxy"
13
15
  require_relative "actual_db_schema/patches/migrator"
14
16
  require_relative "actual_db_schema/patches/migration_context"
17
+ require_relative "actual_db_schema/git_hooks"
18
+ require_relative "actual_db_schema/multi_tenant"
15
19
 
16
20
  require_relative "actual_db_schema/commands/base"
17
21
  require_relative "actual_db_schema/commands/rollback"
@@ -26,11 +30,11 @@ module ActualDbSchema
26
30
  end
27
31
 
28
32
  self.failed = []
29
- self.config = {
30
- enabled: Rails.env.development?,
31
- auto_rollback_disabled: ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?,
32
- ui_enabled: Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
33
- }
33
+ self.config = Configuration.new
34
+
35
+ def self.configure
36
+ yield(config)
37
+ end
34
38
 
35
39
  def self.migrated_folder
36
40
  migrated_folders.first
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ActualDbSchema initializer
4
+ # Adjust the configuration as needed.
5
+
6
+ ActualDbSchema.configure do |config|
7
+ # Enable the gem.
8
+ config.enabled = Rails.env.development?
9
+
10
+ # Disable automatic rollback of phantom migrations.
11
+ # config.auto_rollback_disabled = true
12
+ config.auto_rollback_disabled = ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?
13
+
14
+ # Enable the UI for managing migrations.
15
+ config.ui_enabled = Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
16
+
17
+ # Enable automatic phantom migration rollback on branch switch,
18
+ # config.git_hooks_enabled = true
19
+ config.git_hooks_enabled = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"].present?
20
+
21
+ # If your application leverages multiple schemas for multi-tenancy, define the active schemas.
22
+ # config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
23
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :actual_db_schema do # rubocop:disable Metrics/BlockLength
4
+ desc "Install ActualDbSchema initializer and post-checkout git hook."
5
+ task :install do
6
+ extend ActualDbSchema::OutputFormatter
7
+
8
+ initializer_path = Rails.root.join("config", "initializers", "actual_db_schema.rb")
9
+ initializer_content = File.read(
10
+ File.expand_path("../../lib/generators/actual_db_schema/templates/actual_db_schema.rb", __dir__)
11
+ )
12
+
13
+ if File.exist?(initializer_path)
14
+ puts colorize("[ActualDbSchema] An initializer already exists at #{initializer_path}.", :gray)
15
+ puts "Overwrite the existing file at #{initializer_path}? [y,n] "
16
+ answer = $stdin.gets.chomp.downcase
17
+
18
+ if answer.start_with?("y")
19
+ File.write(initializer_path, initializer_content)
20
+ puts colorize("[ActualDbSchema] Initializer updated successfully at #{initializer_path}", :green)
21
+ else
22
+ puts colorize("[ActualDbSchema] Skipped overwriting the initializer.", :yellow)
23
+ end
24
+ else
25
+ File.write(initializer_path, initializer_content)
26
+ puts colorize("[ActualDbSchema] Initializer created successfully at #{initializer_path}", :green)
27
+ end
28
+
29
+ Rake::Task["actual_db_schema:install_git_hooks"].invoke
30
+ end
31
+
32
+ desc "Install ActualDbSchema post-checkout git hook that rolls back phantom migrations when switching branches."
33
+ task :install_git_hooks do
34
+ extend ActualDbSchema::OutputFormatter
35
+
36
+ puts "Which Git hook strategy would you like to install? [1, 2, 3]"
37
+ puts " 1) Rollback phantom migrations (db:rollback_branches)"
38
+ puts " 2) Migrate up to latest (db:migrate)"
39
+ puts " 3) No hook installation (skip)"
40
+ answer = $stdin.gets.chomp
41
+
42
+ strategy =
43
+ case answer
44
+ when "1" then :rollback
45
+ when "2" then :migrate
46
+ else
47
+ :none
48
+ end
49
+
50
+ if strategy == :none
51
+ puts colorize("[ActualDbSchema] Skipping git hook installation.", :gray)
52
+ else
53
+ ActualDbSchema::GitHooks.new(strategy: strategy).install_post_checkout_hook
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :test do # rubocop:disable Metrics/BlockLength
4
+ desc "Run tests with SQLite3"
5
+ task :sqlite3 do
6
+ ENV["DB_ADAPTER"] = "sqlite3"
7
+ Rake::Task["test"].invoke
8
+ Rake::Task["test"].reenable
9
+ end
10
+
11
+ desc "Run tests with PostgreSQL"
12
+ task :postgresql do
13
+ sh "docker-compose up -d postgres"
14
+ wait_for_postgres
15
+
16
+ begin
17
+ ENV["DB_ADAPTER"] = "postgresql"
18
+ Rake::Task["test"].invoke
19
+ Rake::Task["test"].reenable
20
+ ensure
21
+ sh "docker-compose down"
22
+ end
23
+ end
24
+
25
+ desc "Run tests with MySQL"
26
+ task :mysql2 do
27
+ sh "docker-compose up -d mysql"
28
+ wait_for_mysql
29
+
30
+ begin
31
+ ENV["DB_ADAPTER"] = "mysql2"
32
+ Rake::Task["test"].invoke
33
+ Rake::Task["test"].reenable
34
+ ensure
35
+ sh "docker-compose down"
36
+ end
37
+ end
38
+
39
+ desc "Run tests with all adapters (SQLite3, PostgreSQL, MySQL)"
40
+ task all: %i[sqlite3 postgresql mysql2]
41
+
42
+ def wait_for_postgres
43
+ retries = 10
44
+ begin
45
+ sh "docker-compose exec -T postgres pg_isready -U postgres"
46
+ rescue StandardError
47
+ retries -= 1
48
+
49
+ raise "PostgreSQL is not ready after several attempts." if retries < 1
50
+
51
+ sleep 2
52
+ retry
53
+ end
54
+ end
55
+
56
+ def wait_for_mysql
57
+ retries = 10
58
+ begin
59
+ sh "docker-compose exec -T mysql mysqladmin ping -h 127.0.0.1 --silent"
60
+ rescue StandardError
61
+ retries -= 1
62
+
63
+ raise "MySQL is not ready after several attempts." if retries < 1
64
+
65
+ sleep 2
66
+ retry
67
+ end
68
+ end
69
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actual_db_schema
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.9
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrei Kaleshka
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-09-06 00:00:00.000000000 Z
11
+ date: 2025-01-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -136,6 +136,9 @@ files:
136
136
  - app/views/actual_db_schema/shared/_js.html
137
137
  - app/views/actual_db_schema/shared/_style.html
138
138
  - config/routes.rb
139
+ - docker-compose.yml
140
+ - docker/mysql-init/create_secondary_db.sql
141
+ - docker/postgres-init/create_secondary_db.sql
139
142
  - gemfiles/rails.6.0.gemfile
140
143
  - gemfiles/rails.6.1.gemfile
141
144
  - gemfiles/rails.7.0.gemfile
@@ -145,17 +148,24 @@ files:
145
148
  - lib/actual_db_schema/commands/base.rb
146
149
  - lib/actual_db_schema/commands/list.rb
147
150
  - lib/actual_db_schema/commands/rollback.rb
151
+ - lib/actual_db_schema/configuration.rb
148
152
  - lib/actual_db_schema/engine.rb
149
153
  - lib/actual_db_schema/failed_migration.rb
150
154
  - lib/actual_db_schema/git.rb
155
+ - lib/actual_db_schema/git_hooks.rb
151
156
  - lib/actual_db_schema/migration.rb
152
157
  - lib/actual_db_schema/migration_context.rb
158
+ - lib/actual_db_schema/multi_tenant.rb
159
+ - lib/actual_db_schema/output_formatter.rb
153
160
  - lib/actual_db_schema/patches/migration_context.rb
154
161
  - lib/actual_db_schema/patches/migration_proxy.rb
155
162
  - lib/actual_db_schema/patches/migrator.rb
156
163
  - lib/actual_db_schema/store.rb
157
164
  - lib/actual_db_schema/version.rb
165
+ - lib/generators/actual_db_schema/templates/actual_db_schema.rb
166
+ - lib/tasks/actual_db_schema.rake
158
167
  - lib/tasks/db.rake
168
+ - lib/tasks/test.rake
159
169
  - sig/actual_db_schema.rbs
160
170
  homepage: https://blog.widefix.com/actual-db-schema/
161
171
  licenses:
@@ -163,8 +173,17 @@ licenses:
163
173
  metadata:
164
174
  homepage_uri: https://blog.widefix.com/actual-db-schema/
165
175
  source_code_uri: https://github.com/widefix/actual_db_schema
166
- changelog_uri: https://blog.widefix.com/actual-db-schema//blob/main/CHANGELOG.md
167
- post_install_message:
176
+ changelog_uri: https://github.com/widefix/actual_db_schema/blob/main/CHANGELOG.md
177
+ post_install_message: |+
178
+ Thank you for installing ActualDbSchema!
179
+
180
+ Next steps:
181
+ 1. Run `rake actual_db_schema:install` to generate the initializer file and install
182
+ the post-checkout Git hook for automatic phantom migration rollback when switching branches.
183
+ 2. Or, if you prefer environment variables, skip this step.
184
+
185
+ For more information, see the README.
186
+
168
187
  rdoc_options: []
169
188
  require_paths:
170
189
  - lib
@@ -184,3 +203,4 @@ signing_key:
184
203
  specification_version: 4
185
204
  summary: Keep your DB and schema.rb consistent in dev branches.
186
205
  test_files: []
206
+ ...