actual_db_schema 0.7.9 → 0.8.1

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: 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
+ ...