` (optional) – Specify a database if using multiple databases.
#### Examples:
```sh
# Delete all broken migrations
rake actual_db_schema:delete_broken_versions
# Delete specific migrations
rake actual_db_schema:delete_broken_versions["20250224103352 20250224103358"]
# Delete specific migrations from a specific database
rake actual_db_schema:delete_broken_versions["20250224103352 20250224103358", "primary"]
```
## 🏗️ Development
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run `bundle exec rake install`.
To release a new version do the following in the order:
- update the version number in `version.rb`;
- update the CHANGELOG;
- `bundle install` to update `Gemfile.lock`;
- make the commit and push;
- run `bundle exec rake release`. This will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org);
- [announce the new release on GitHub](https://github.com/widefix/actual_db_schema/releases);
- close the milestone on GitHub.
### Running Tests with Specific Rails Versions
The following versions can be specifically tested using Appraisal
- 6.0
- 6.1
- 7.0
- 7.1
- edge
To run tests with a specific version of Rails using Appraisal:
- Run all tests with Rails 6.0:
```sh
bundle exec appraisal rails.6.0 rake test
```
- Run tests for a specific file:
```sh
bundle exec appraisal rails.6.0 rake test TEST=test/rake_task_test.rb
```
- Run a specific test:
```sh
bundle exec appraisal rails.6.0 rake test TEST=test/rake_task_test.rb TESTOPTS="--name=/db::db:rollback_branches#test_0003_keeps/"
```
By default, `rake test` runs tests using `SQLite3`. To explicitly run tests with `SQLite3`, `PostgreSQL`, or `MySQL`, you can use the following tasks:
- Run tests with `SQLite3`:
```sh
bundle exec rake test:sqlite3
```
- Run tests with `PostgreSQL` (requires Docker):
```sh
bundle exec rake test:postgresql
```
- Run tests with `MySQL` (requires Docker):
```sh
bundle exec rake test:mysql2
```
- Run tests for all supported adapters:
```sh
bundle exec rake test:all
```
## Contributing
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).
## License
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
## Code of Conduct
Everyone interacting in the ActualDbSchema project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/widefix/actual_db_schema/blob/master/CODE_OF_CONDUCT.md).
================================================
FILE: Rakefile
================================================
# frozen_string_literal: true
require "bundler/gem_tasks"
require "rake/testtask"
load "lib/tasks/test.rake"
Rake::TestTask.new(:test) do |t|
t.libs << "test"
t.libs << "lib"
t.test_files = FileList["test/**/test_*.rb", "test/**/*_test.rb"]
end
require "rubocop/rake_task"
RuboCop::RakeTask.new
task default: %i[test]
================================================
FILE: actual_db_schema.gemspec
================================================
# frozen_string_literal: true
require_relative "lib/actual_db_schema/version"
Gem::Specification.new do |spec|
spec.name = "actual_db_schema"
spec.version = ActualDbSchema::VERSION
spec.authors = ["Andrei Kaleshka"]
spec.email = ["ka8725@gmail.com"]
spec.summary = "Keep DB schema in sync across branches effortlessly."
spec.description = <<~DESC
Keep your DB schema in sync across all branches effortlessly.
Install once, then use `rails db:migrate` normally — the gem handles phantom migration rollback automatically, eliminating schema conflicts and inconsistent database states.
Stop wasting hours on DB maintenance locally, CI, staging/sandbox, or even production.
DESC
spec.homepage = "https://blog.widefix.com/actual-db-schema/"
spec.license = "MIT"
spec.required_ruby_version = ">= 2.7.0"
# spec.metadata["allowed_push_host"] = "TODO: Set to your gem server 'https://example.com'"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://github.com/widefix/actual_db_schema"
spec.metadata["changelog_uri"] = "https://github.com/widefix/actual_db_schema/blob/main/CHANGELOG.md"
# Specify which files should be added to the gem when it is released.
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
spec.files = Dir.chdir(File.expand_path(__dir__)) do
`git ls-files -z`.split("\x0").reject do |f|
(f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
end
end
spec.bindir = "exe"
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
# Uncomment to register a new dependency of your gem
spec.add_runtime_dependency "activerecord"
spec.add_runtime_dependency "activesupport"
spec.add_runtime_dependency "ast"
spec.add_runtime_dependency "csv"
spec.add_runtime_dependency "parser"
spec.add_runtime_dependency "prism"
spec.add_development_dependency "appraisal"
spec.add_development_dependency "debug"
spec.add_development_dependency "rails"
spec.add_development_dependency "sqlite3"
spec.post_install_message = <<~MSG
Thank you for installing ActualDbSchema!
Next steps:
1. Run `rake actual_db_schema:install` to generate the initializer file and install
the post-checkout Git hook for automatic phantom migration rollback when switching branches.
2. Or, if you prefer environment variables, skip this step.
For more information, see the README.
MSG
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
end
================================================
FILE: app/controllers/actual_db_schema/broken_versions_controller.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Controller for managing broken migration versions.
class BrokenVersionsController < ActionController::Base
protect_from_forgery with: :exception
skip_before_action :verify_authenticity_token
def index; end
def delete
handle_delete(params[:id], params[:database])
redirect_to broken_versions_path
end
def delete_all
handle_delete_all
redirect_to broken_versions_path
end
private
def handle_delete(id, database)
ActualDbSchema::Migration.instance.delete(id, database)
flash[:notice] = "Migration #{id} was successfully deleted."
rescue StandardError => e
flash[:alert] = e.message
end
def handle_delete_all
ActualDbSchema::Migration.instance.delete_all
flash[:notice] = "All broken versions were successfully deleted."
rescue StandardError => e
flash[:alert] = e.message
end
helper_method def broken_versions
@broken_versions ||= ActualDbSchema::Migration.instance.broken_versions
end
end
end
================================================
FILE: app/controllers/actual_db_schema/migrations_controller.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Controller to display the list of migrations for each database connection.
class MigrationsController < ActionController::Base
protect_from_forgery with: :exception
skip_before_action :verify_authenticity_token
def index; end
def show
render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found unless migration
end
def rollback
handle_rollback(params[:id], params[:database])
redirect_to migrations_path
end
def migrate
handle_migrate(params[:id], params[:database])
redirect_to migrations_path
end
private
def handle_rollback(id, database)
ActualDbSchema::Migration.instance.rollback(id, database)
flash[:notice] = "Migration #{id} was successfully rolled back."
rescue StandardError => e
flash[:alert] = e.message
end
def handle_migrate(id, database)
ActualDbSchema::Migration.instance.migrate(id, database)
flash[:notice] = "Migration #{id} was successfully migrated."
rescue StandardError => e
flash[:alert] = e.message
end
helper_method def migrations
@migrations ||= ActualDbSchema::Migration.instance.all
query = params[:query].to_s.strip.downcase
return @migrations if query.blank?
@migrations.select do |migration|
file_name_matches = migration[:filename].include?(query)
content_matches = begin
File.read(migration[:filename]).downcase.include?(query)
rescue StandardError
false
end
file_name_matches || content_matches
end
end
helper_method def migration
@migration ||= ActualDbSchema::Migration.instance.find(params[:id], params[:database])
end
end
end
================================================
FILE: app/controllers/actual_db_schema/phantom_migrations_controller.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Controller to display the list of phantom migrations for each database connection.
class PhantomMigrationsController < ActionController::Base
protect_from_forgery with: :exception
skip_before_action :verify_authenticity_token
def index; end
def show
render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found unless phantom_migration
end
def rollback
handle_rollback(params[:id], params[:database])
redirect_to phantom_migrations_path
end
def rollback_all
handle_rollback_all
redirect_to phantom_migrations_path
end
private
def handle_rollback(id, database)
ActualDbSchema::Migration.instance.rollback(id, database)
flash[:notice] = "Migration #{id} was successfully rolled back."
rescue StandardError => e
flash[:alert] = e.message
end
def handle_rollback_all
ActualDbSchema::Migration.instance.rollback_all
flash[:notice] = "Migrations was successfully rolled back."
rescue StandardError => e
flash[:alert] = e.message
end
helper_method def phantom_migrations
@phantom_migrations ||= ActualDbSchema::Migration.instance.all_phantom
end
helper_method def phantom_migration
@phantom_migration ||= ActualDbSchema::Migration.instance.find(params[:id], params[:database])
end
end
end
================================================
FILE: app/controllers/actual_db_schema/schema_controller.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Controller to display the database schema diff.
class SchemaController < ActionController::Base
protect_from_forgery with: :exception
skip_before_action :verify_authenticity_token
def index; end
private
helper_method def schema_diff_html
schema_path = Rails.configuration.active_record.schema_format == :sql ? "./db/structure.sql" : "./db/schema.rb"
schema_diff = ActualDbSchema::SchemaDiffHtml.new(schema_path, "db/migrate")
schema_diff.render_html(params[:table])
end
end
end
================================================
FILE: app/views/actual_db_schema/broken_versions/index.html.erb
================================================
Broken Versions
<%= render partial: 'actual_db_schema/shared/js' %>
<%= render partial: 'actual_db_schema/shared/style' %>
<% flash.each do |key, message| %>
<%= message %>
<% end %>
Broken Versions
These are versions that were migrated in the database, but the corresponding migration file is missing.
You can safely delete them from the database to clean up.
<%= link_to 'All Migrations', migrations_path, class: "top-button" %>
<% if broken_versions.present? %>
<%= button_to '✖ Delete all',
delete_all_broken_versions_path,
method: :post,
data: { confirm: 'These migrations do not have corresponding migration files. Proceeding will remove these entries from the `schema_migrations` table. Are you sure you want to continue?' },
class: 'button migration-action' %>
<% end %>
<% if broken_versions.present? %>
| Status |
Migration ID |
Branch |
Database |
Actions |
<% broken_versions.each do |version| %>
| <%= version[:status] %> |
<%= version[:version] %> |
<%= version[:branch] %> |
<%= version[:database] %> |
<%= button_to '✖ Delete',
delete_broken_version_path(id: version[:version], database: version[:database]),
method: :post,
data: { confirm: 'This migration does not have a corresponding migration file. Proceeding will remove its entry from the `schema_migrations` table. Are you sure you want to continue?' },
class: 'button migration-action' %>
|
<% end %>
<% else %>
No broken versions found.
<% end %>
================================================
FILE: app/views/actual_db_schema/migrations/index.html.erb
================================================
Migrations
<%= render partial: 'actual_db_schema/shared/js' %>
<%= render partial: 'actual_db_schema/shared/style' %>
<% flash.each do |key, message| %>
<%= message %>
<% end %>
Migrations
Red rows represent phantom migrations.
<%= link_to 'Phantom Migrations', phantom_migrations_path, class: "top-button" %>
<%= link_to 'Broken Versions', broken_versions_path, class: "top-button" %>
<%= link_to 'View Schema', schema_path, class: "top-button" %>
<%= form_tag migrations_path, method: :get, class: "search-form" do %>
🔍
<%= text_field_tag :query, params[:query], placeholder: "Search migrations by name or content", class: "search-input" %>
<% end %>
<% if migrations.present? %>
| Status |
Migration ID |
Name |
Branch |
Database |
Actions |
<% migrations.each do |migration| %>
| <%= migration[:status] %> |
<%= migration[:version] %> |
<%= migration[:name] %>
|
<%= migration[:branch] %> |
<%= migration[:database] %> |
<%= link_to '👁 Show',
migration_path(id: migration[:version], database: migration[:database]),
class: 'button' %>
<%= button_to '⎌ Rollback',
rollback_migration_path(id: migration[:version], database: migration[:database]),
method: :post,
class: 'button migration-action',
style: ('display: none;' if migration[:status] == "down") %>
<%= button_to '⬆ Migrate',
migrate_migration_path(id: migration[:version], database: migration[:database]),
method: :post,
class: 'button migration-action',
style: ('display: none;' if migration[:status] == "up" || migration[:phantom]) %>
|
<% end %>
<% else %>
No migrations found.
<% end %>
================================================
FILE: app/views/actual_db_schema/migrations/show.html.erb
================================================
Migration Details
<%= render partial: 'actual_db_schema/shared/js' %>
<%= render partial: 'actual_db_schema/shared/style' %>
<% flash.each do |key, message| %>
<%= message %>
<% end %>
Migration <%= migration[:name] %> Details
| Status |
<%= migration[:status] %> |
| Migration ID |
<%= migration[:version] %> |
| Branch |
<%= migration[:branch] %> |
| Database |
<%= migration[:database] %> |
| Path |
<%= migration[:filename] %>
<% source = migration[:source].to_s %>
<% if source.present? %>
<%= source.upcase %>
<% end %>
|
Migration Code
<%= File.read(migration[:filename]) %>
<%= link_to '← Back', migrations_path, class: 'button' %>
<%= button_to '⎌ Rollback',
rollback_migration_path(id: migration[:version], database: migration[:database]),
method: :post,
class: 'button migration-action',
style: ('display: none;' if migration[:status] == "down") %>
<%= button_to '⬆ Migrate',
migrate_migration_path(id: migration[:version], database: migration[:database]),
method: :post,
class: 'button migration-action',
style: ('display: none;' if migration[:status] == "up" || migration[:phantom]) %>
================================================
FILE: app/views/actual_db_schema/phantom_migrations/index.html.erb
================================================
Phantom Migrations
<%= render partial: 'actual_db_schema/shared/js' %>
<%= render partial: 'actual_db_schema/shared/style' %>
<% flash.each do |key, message| %>
<%= message %>
<% end %>
Phantom Migrations
<%= link_to 'All Migrations', migrations_path, class: "top-button" %>
<% if phantom_migrations.present? %>
<%= button_to '⎌ Rollback all',
rollback_all_phantom_migrations_path,
method: :post,
class: 'button migration-action' %>
<% end %>
<% if phantom_migrations.present? %>
| Status |
Migration ID |
Name |
Branch |
Database |
Actions |
<% phantom_migrations.each do |migration| %>
| <%= migration[:status] %> |
<%= migration[:version] %> |
<%= migration[:name] %>
|
<%= migration[:branch] %> |
<%= migration[:database] %> |
<%= link_to '👁 Show',
phantom_migration_path(id: migration[:version], database: migration[:database]),
class: 'button' %>
<%= button_to '⎌ Rollback',
rollback_phantom_migration_path(id: migration[:version], database: migration[:database]),
method: :post,
class: 'button migration-action' %>
|
<% end %>
<% else %>
No phantom migrations found.
<% end %>
================================================
FILE: app/views/actual_db_schema/phantom_migrations/show.html.erb
================================================
Phantom Migration Details
<%= render partial: 'actual_db_schema/shared/js' %>
<%= render partial: 'actual_db_schema/shared/style' %>
<% flash.each do |key, message| %>
<%= message %>
<% end %>
Phantom Migration <%= phantom_migration[:name] %> Details
| Status |
<%= phantom_migration[:status] %> |
| Migration ID |
<%= phantom_migration[:version] %> |
| Branch |
<%= phantom_migration[:branch] %> |
| Database |
<%= phantom_migration[:database] %> |
| Path |
<%= phantom_migration[:filename] %>
<% source = phantom_migration[:source].to_s %>
<% if source.present? %>
<%= source.upcase %>
<% end %>
|
Migration Code
<%= File.read(phantom_migration[:filename]) %>
<%= link_to '← Back', phantom_migrations_path, class: 'button' %>
<%= button_to '⎌ Rollback',
rollback_phantom_migration_path(id: params[:id], database: params[:database]),
method: :post,
class: 'button migration-action' %>
================================================
FILE: app/views/actual_db_schema/schema/index.html.erb
================================================
Database Schema
<%= render partial: 'actual_db_schema/shared/js' %>
<%= render partial: 'actual_db_schema/shared/style' %>
<% flash.each do |key, message| %>
<%= message %>
<% end %>
Database Schema
<%= link_to 'All Migrations', migrations_path, class: "top-button" %>
<%= form_tag schema_path, method: :get, class: "search-form" do %>
🔍
<%= text_field_tag :table, params[:table], placeholder: "Filter by table name", class: "search-input" %>
<% end %>
<%= raw schema_diff_html %>
================================================
FILE: app/views/actual_db_schema/shared/_js.html
================================================
================================================
FILE: app/views/actual_db_schema/shared/_style.html
================================================
================================================
FILE: bin/console
================================================
#!/usr/bin/env ruby
# frozen_string_literal: true
require "bundler/setup"
require "actual_db_schema"
# You can add fixtures and/or initialization code here to make experimenting
# with your gem easier. You can also use a different console, if you like.
# (If you use this, don't forget to add pry to your Gemfile!)
# require "pry"
# Pry.start
require "irb"
IRB.start(__FILE__)
================================================
FILE: bin/setup
================================================
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -vx
bundle install
# Do any other automated setup that you need to do here
================================================
FILE: config/routes.rb
================================================
# frozen_string_literal: true
ActualDbSchema::Engine.routes.draw do
resources :migrations, only: %i[index show] do
member do
post :rollback
post :migrate
end
end
resources :phantom_migrations, only: %i[index show] do
member do
post :rollback
end
collection do
post :rollback_all
end
end
resources :broken_versions, only: %i[index] do
member do
post :delete
end
collection do
post :delete_all
end
end
get "schema", to: "schema#index", as: :schema
end
================================================
FILE: docker/mysql-init/create_secondary_db.sql
================================================
CREATE DATABASE actual_db_schema_test_secondary;
================================================
FILE: docker/postgres-init/create_secondary_db.sql
================================================
CREATE DATABASE actual_db_schema_test_secondary;
================================================
FILE: docker-compose.yml
================================================
version: '3.8'
services:
postgres:
image: postgres:14
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password
POSTGRES_DB: actual_db_schema_test
ports:
- "5432:5432"
volumes:
- ./docker/postgres-init:/docker-entrypoint-initdb.d
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: actual_db_schema_test
ports:
- "3306:3306"
volumes:
- ./docker/mysql-init:/docker-entrypoint-initdb.d
================================================
FILE: gemfiles/rails.6.0.gemfile
================================================
# frozen_string_literal: true
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", "~> 6.0.0"
gem "activesupport", "~> 6.0.0"
gem "minitest", "~> 5.0"
gem "mysql2", "~> 0.5.2"
gem "pg", "~> 1.5"
gem "rake"
gem "rubocop", "~> 1.21"
gem "sqlite3", "~> 1.4.0"
gemspec path: "../"
================================================
FILE: gemfiles/rails.6.1.gemfile
================================================
# frozen_string_literal: true
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", "~> 6.1.0"
gem "activesupport", "~> 6.1.0"
gem "minitest", "~> 5.0"
gem "mysql2", "~> 0.5.2"
gem "pg", "~> 1.5"
gem "rake"
gem "rubocop", "~> 1.21"
gem "sqlite3", "~> 1.4.0"
gemspec path: "../"
================================================
FILE: gemfiles/rails.7.0.gemfile
================================================
# frozen_string_literal: true
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", "~> 7.0.0"
gem "activesupport", "~> 7.0.0"
gem "minitest", "~> 5.0"
gem "mysql2", "~> 0.5.2"
gem "pg", "~> 1.5"
gem "rake"
gem "rubocop", "~> 1.21"
gem "sqlite3", "~> 1.4.0"
gemspec path: "../"
================================================
FILE: gemfiles/rails.7.1.gemfile
================================================
# frozen_string_literal: true
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", "~> 7.1.0"
gem "activesupport", "~> 7.1.0"
gem "minitest", "~> 5.0"
gem "mysql2", "~> 0.5.2"
gem "pg", "~> 1.5"
gem "rake"
gem "rubocop", "~> 1.21"
gem "sqlite3", "~> 1.4.0"
gemspec path: "../"
================================================
FILE: gemfiles/rails.edge.gemfile
================================================
# frozen_string_literal: true
# This file was generated by Appraisal
source "https://rubygems.org"
gem "activerecord", ">= 7.2.0.beta"
gem "activesupport", ">= 7.2.0.beta"
gem "minitest", "~> 5.0"
gem "mysql2", "~> 0.5.2"
gem "pg", "~> 1.5"
gem "rake"
gem "rubocop", "~> 1.21"
gem "rails", ">= 7.2.0.beta"
gem "sqlite3"
gemspec path: "../"
================================================
FILE: lib/actual_db_schema/commands/base.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Commands
# Base class for all commands
class Base
attr_reader :context
def initialize(context)
@context = context
end
def call
unless ActualDbSchema.config.fetch(:enabled, true)
raise "ActualDbSchema is disabled. Set ActualDbSchema.config[:enabled] = true to enable it."
end
call_impl
end
private
def call_impl
raise NotImplementedError
end
end
end
end
================================================
FILE: lib/actual_db_schema/commands/list.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Commands
# Shows the list of phantom migrations
class List < Base
private
def call_impl
preambule
table
end
def indexed_phantom_migrations
@indexed_phantom_migrations ||= context.phantom_migrations.index_by { |m| m.version.to_s }
end
def preambule
puts "\nPhantom migrations\n\n"
puts "Below is a list of irrelevant migrations executed in unmerged branches."
puts "To bring your database schema up to date, the migrations marked as \"up\" should be rolled back."
puts "\ndatabase: #{ActualDbSchema.db_config[:database]}\n\n"
puts header.join(" ")
puts "-" * separator_width
end
def separator_width
header.map(&:length).sum + (header.size - 1) * 2
end
def header
@header ||=
[
"Status".center(8),
"Migration ID".ljust(14),
"Branch".ljust(branch_column_width),
"Migration File".ljust(16)
]
end
def table
context.migrations_status.each do |status, version|
line = line_for(status, version)
puts line if line
end
end
def line_for(status, version)
migration = indexed_phantom_migrations[version]
return unless migration
[
status.center(8),
version.to_s.ljust(14),
branch_for(version).ljust(branch_column_width),
migration.filename.gsub("#{Rails.root}/", "")
].join(" ")
end
def metadata
@metadata ||= ActualDbSchema::Store.instance.read
end
def branch_for(version)
metadata.fetch(version, {})[:branch] || "unknown"
end
def longest_branch_name
@longest_branch_name ||=
metadata.values.map { |v| v[:branch] }.compact.max_by(&:length) || "unknown"
end
def branch_column_width
longest_branch_name.length
end
end
end
end
================================================
FILE: lib/actual_db_schema/commands/rollback.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Commands
# Rolls back all phantom migrations
class Rollback < Base
include ActualDbSchema::OutputFormatter
include ActionView::Helpers::TextHelper
def initialize(context, manual_mode: false)
@manual_mode = manual_mode || manual_mode_default?
super(context)
end
private
def call_impl
rolled_back = context.rollback_branches(manual_mode: @manual_mode)
return unless rolled_back || ActualDbSchema.failed.any?
ActualDbSchema.failed.empty? ? print_success : print_error
end
def print_success
puts colorize("[ActualDbSchema] All phantom migrations rolled back successfully! 🎉", :green)
end
def print_error
header_message = <<~HEADER
#{ActualDbSchema.failed.count} phantom migration(s) could not be rolled back automatically.
Try these steps to fix and move forward:
1. Ensure the migrations are reversible (define #up and #down methods or use #reversible).
2. If the migration references code or tables from another branch, restore or remove them.
3. Once fixed, run `rails db:migrate` again.
Below are the details of the problematic migrations:
HEADER
print_error_summary("#{header_message}\n#{failed_migrations_list}")
end
def failed_migrations_list
ActualDbSchema.failed.map.with_index(1) do |failed, index|
migration_details = colorize("Migration ##{index}:\n", :yellow)
migration_details += " File: #{failed.short_filename}\n"
migration_details += " Schema: #{failed.schema}\n" if failed.schema
migration_details + " Branch: #{failed.branch}\n"
end.join("\n")
end
def print_error_summary(content)
width = 100
indent = 4
gem_name = "ActualDbSchema"
puts colorize("╔═ [#{gem_name}] #{"═" * (width - gem_name.length - 5)}╗", :red)
print_wrapped_content(content, width, indent)
puts colorize("╚#{"═" * width}╝", :red)
end
def print_wrapped_content(content, width, indent)
usable_width = width - indent - 4
wrapped_content = word_wrap(content, line_width: usable_width)
wrapped_content.each_line do |line|
puts "#{" " * indent}#{line.chomp}"
end
end
def manual_mode_default?
ActualDbSchema.config[:auto_rollback_disabled]
end
end
end
end
================================================
FILE: lib/actual_db_schema/configuration.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Manages the configuration settings for the gem.
class Configuration
attr_accessor :enabled, :auto_rollback_disabled, :ui_enabled, :git_hooks_enabled, :multi_tenant_schemas,
:console_migrations_enabled, :migrated_folder, :migrations_storage, :excluded_databases
def initialize
apply_defaults(default_settings)
end
def [](key)
public_send(key)
end
def []=(key, value)
public_send("#{key}=", value)
return unless key.to_sym == :migrations_storage && defined?(ActualDbSchema::Store)
ActualDbSchema::Store.instance.reset_adapter
end
def fetch(key, default = nil)
if respond_to?(key)
public_send(key)
else
default
end
end
private
def default_settings
{
enabled: enabled_by_default?,
auto_rollback_disabled: env_enabled?("ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"),
ui_enabled: ui_enabled_by_default?,
git_hooks_enabled: env_enabled?("ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"),
multi_tenant_schemas: nil,
console_migrations_enabled: env_enabled?("ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"),
migrated_folder: ENV["ACTUAL_DB_SCHEMA_MIGRATED_FOLDER"].present?,
migrations_storage: migrations_storage_from_env,
excluded_databases: parse_excluded_databases_env
}
end
def enabled_by_default?
Rails.env.development?
end
def ui_enabled_by_default?
Rails.env.development? || env_enabled?("ACTUAL_DB_SCHEMA_UI_ENABLED")
end
def env_enabled?(key)
ENV[key].present?
end
def migrations_storage_from_env
ENV.fetch("ACTUAL_DB_SCHEMA_MIGRATIONS_STORAGE", "file").to_sym
end
def parse_excluded_databases_env
return [] unless ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"].present?
ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"]
.split(",")
.map(&:strip)
.reject(&:empty?)
.map(&:to_sym)
end
def apply_defaults(settings)
settings.each do |key, value|
instance_variable_set("@#{key}", value)
end
end
end
end
================================================
FILE: lib/actual_db_schema/console_migrations.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Provides methods for executing schema modification commands directly in the Rails console.
module ConsoleMigrations
extend self
SCHEMA_METHODS = %i[
create_table
create_join_table
drop_table
change_table
add_column
remove_column
change_column
change_column_null
change_column_default
rename_column
add_index
remove_index
rename_index
add_timestamps
remove_timestamps
reversible
add_reference
remove_reference
add_foreign_key
remove_foreign_key
].freeze
SCHEMA_METHODS.each do |method_name|
define_method(method_name) do |*args, **kwargs, &block|
if kwargs.any?
migration_instance.public_send(method_name, *args, **kwargs, &block)
else
migration_instance.public_send(method_name, *args, &block)
end
end
end
private
def migration_instance
@migration_instance ||= Class.new(ActiveRecord::Migration[ActiveRecord::Migration.current_version]) {}.new
end
end
end
================================================
FILE: lib/actual_db_schema/engine.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# It isolates the namespace to avoid conflicts with the main application.
class Engine < ::Rails::Engine
isolate_namespace ActualDbSchema
initializer "actual_db_schema.initialize" do |app|
if ActualDbSchema.config[:ui_enabled]
app.routes.append do
mount ActualDbSchema::Engine => "/rails"
end
end
end
initializer "actual_db_schema.schema_dump_exclusions" do
ActiveSupport.on_load(:active_record) do
ActualDbSchema::Engine.apply_schema_dump_exclusions
end
end
def self.apply_schema_dump_exclusions
ignore_schema_dump_table(ActualDbSchema::Store::DbAdapter::TABLE_NAME)
ignore_schema_dump_table(ActualDbSchema::RollbackStatsRepository::TABLE_NAME)
return unless schema_dump_flags_supported?
return unless schema_dump_connection_available?
apply_structure_dump_flags(ActualDbSchema::Store::DbAdapter::TABLE_NAME)
apply_structure_dump_flags(ActualDbSchema::RollbackStatsRepository::TABLE_NAME)
end
class << self
private
def ignore_schema_dump_table(table_name)
return unless defined?(ActiveRecord::SchemaDumper)
return unless ActiveRecord::SchemaDumper.respond_to?(:ignore_tables)
ActiveRecord::SchemaDumper.ignore_tables |= [table_name]
end
def schema_dump_flags_supported?
defined?(ActiveRecord::Tasks::DatabaseTasks) &&
ActiveRecord::Tasks::DatabaseTasks.respond_to?(:structure_dump_flags)
end
# Avoid touching db config unless we explicitly use DB storage
# or a connection is already available.
def schema_dump_connection_available?
has_connection = begin
ActiveRecord::Base.connection_pool.connected?
rescue ActiveRecord::ConnectionNotDefined, ActiveRecord::ConnectionNotEstablished
false
end
ActualDbSchema.config[:migrations_storage] == :db || has_connection
end
def apply_structure_dump_flags(table_name)
flags = Array(ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags)
adapter = ActualDbSchema.db_config[:adapter].to_s
database = database_name
if adapter.match?(/postgres/i)
flag = "--exclude-table=#{table_name}*"
flags << flag unless flags.include?(flag)
elsif adapter.match?(/mysql/i) && database
flag = "--ignore-table=#{database}.#{table_name}"
flags << flag unless flags.include?(flag)
end
ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = flags
end
def database_name
database = ActualDbSchema.db_config[:database]
if database.nil? && ActiveRecord::Base.respond_to?(:connection_db_config)
database = ActiveRecord::Base.connection_db_config&.database
end
database
end
end
end
end
================================================
FILE: lib/actual_db_schema/failed_migration.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
FailedMigration = Struct.new(:migration, :exception, :branch, :schema, keyword_init: true) do
def filename
migration.filename
end
def short_filename
migration.filename.sub(File.join(Rails.root, "/"), "")
end
end
end
================================================
FILE: lib/actual_db_schema/git.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Git helper
class Git
def self.current_branch
branch = `git rev-parse --abbrev-ref HEAD 2>/dev/null`.strip
branch.empty? ? "unknown" : branch
rescue Errno::ENOENT
"unknown"
end
end
end
================================================
FILE: lib/actual_db_schema/git_hooks.rb
================================================
# frozen_string_literal: true
require "fileutils"
module ActualDbSchema
# Handles the installation of a git post-checkout hook that rolls back phantom migrations when switching branches
class GitHooks
include ActualDbSchema::OutputFormatter
POST_CHECKOUT_MARKER_START = "# >>> BEGIN ACTUAL_DB_SCHEMA"
POST_CHECKOUT_MARKER_END = "# <<< END ACTUAL_DB_SCHEMA"
POST_CHECKOUT_HOOK_ROLLBACK = <<~BASH
#{POST_CHECKOUT_MARKER_START}
# ActualDbSchema post-checkout hook (ROLLBACK)
# Runs db:rollback_branches on branch checkout.
# Check if this is a file checkout or creating a new branch
if [ "$3" == "0" ] || [ "$1" == "$2" ]; then
exit 0
fi
if [ -f ./bin/rails ]; then
if [ -n "$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED" ]; then
GIT_HOOKS_ENABLED="$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"
else
GIT_HOOKS_ENABLED=$(./bin/rails runner "puts ActualDbSchema.config[:git_hooks_enabled]" 2>/dev/null)
fi
if [ "$GIT_HOOKS_ENABLED" == "true" ]; then
./bin/rails db:rollback_branches
fi
fi
#{POST_CHECKOUT_MARKER_END}
BASH
POST_CHECKOUT_HOOK_MIGRATE = <<~BASH
#{POST_CHECKOUT_MARKER_START}
# ActualDbSchema post-checkout hook (MIGRATE)
# Runs db:migrate on branch checkout.
# Check if this is a file checkout or creating a new branch
if [ "$3" == "0" ] || [ "$1" == "$2" ]; then
exit 0
fi
if [ -f ./bin/rails ]; then
if [ -n "$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED" ]; then
GIT_HOOKS_ENABLED="$ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"
else
GIT_HOOKS_ENABLED=$(./bin/rails runner "puts ActualDbSchema.config[:git_hooks_enabled]" 2>/dev/null)
fi
if [ "$GIT_HOOKS_ENABLED" == "true" ]; then
./bin/rails db:migrate
fi
fi
#{POST_CHECKOUT_MARKER_END}
BASH
def initialize(strategy: :rollback)
@strategy = strategy
end
def install_post_checkout_hook
return unless hooks_directory_present?
if File.exist?(hook_path)
handle_existing_hook
else
create_new_hook
end
end
private
def hook_code
@strategy == :migrate ? POST_CHECKOUT_HOOK_MIGRATE : POST_CHECKOUT_HOOK_ROLLBACK
end
def hooks_dir
@hooks_dir ||= Rails.root.join(".git", "hooks")
end
def hook_path
@hook_path ||= hooks_dir.join("post-checkout")
end
def hooks_directory_present?
return true if Dir.exist?(hooks_dir)
puts colorize("[ActualDbSchema] .git/hooks directory not found. Please ensure this is a Git repository.", :gray)
end
def handle_existing_hook
return update_hook if markers_exist?
return install_hook if safe_install?
show_manual_install_instructions
end
def create_new_hook
contents = <<~BASH
#!/usr/bin/env bash
#{hook_code}
BASH
write_hook_file(contents)
print_success
end
def markers_exist?
contents = File.read(hook_path)
contents.include?(POST_CHECKOUT_MARKER_START) && contents.include?(POST_CHECKOUT_MARKER_END)
end
def update_hook
contents = File.read(hook_path)
new_contents = replace_marker_contents(contents)
if new_contents == contents
message = "[ActualDbSchema] post-checkout git hook already contains the necessary code. Nothing to update."
puts colorize(message, :gray)
else
write_hook_file(new_contents)
puts colorize("[ActualDbSchema] post-checkout git hook updated successfully at #{hook_path}", :green)
end
end
def replace_marker_contents(contents)
contents.gsub(
/#{Regexp.quote(POST_CHECKOUT_MARKER_START)}.*#{Regexp.quote(POST_CHECKOUT_MARKER_END)}/m,
hook_code.strip
)
end
def safe_install?
puts colorize("[ActualDbSchema] A post-checkout hook already exists at #{hook_path}.", :gray)
puts "Overwrite the existing hook at #{hook_path}? [y,n] "
answer = $stdin.gets.chomp.downcase
answer.start_with?("y")
end
def install_hook
contents = File.read(hook_path)
new_contents = <<~BASH
#{contents.rstrip}
#{hook_code}
BASH
write_hook_file(new_contents)
print_success
end
def show_manual_install_instructions
puts colorize("[ActualDbSchema] You can follow these steps to manually install the hook:", :yellow)
puts <<~MSG
1. Open the existing post-checkout hook at:
#{hook_path}
2. Insert the following lines into that file (preferably at the end or in a relevant section).
Make sure you include the #{POST_CHECKOUT_MARKER_START} and #{POST_CHECKOUT_MARKER_END} lines:
#{hook_code}
3. Ensure the post-checkout file is executable:
chmod +x #{hook_path}
4. Done! Now when you switch branches, phantom migrations will be rolled back automatically (if enabled).
MSG
end
def write_hook_file(contents)
File.open(hook_path, "w") { |file| file.write(contents) }
FileUtils.chmod("+x", hook_path)
end
def print_success
puts colorize("[ActualDbSchema] post-checkout git hook installed successfully at #{hook_path}", :green)
end
end
end
================================================
FILE: lib/actual_db_schema/instrumentation.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Instrumentation
ROLLBACK_EVENT = "rollback.actual_db_schema"
end
end
================================================
FILE: lib/actual_db_schema/migration.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# The Migration class is responsible for managing and retrieving migration information
class Migration
include Singleton
Migration = Struct.new(:status, :version, :name, :branch, :database, :filename, :phantom, :source,
keyword_init: true)
def all_phantom
migrations = []
MigrationContext.instance.each do |context|
indexed_migrations = context.phantom_migrations.index_by { |m| m.version.to_s }
context.migrations_status.each do |status, version|
migration = indexed_migrations[version]
migrations << build_migration_struct(status, migration) if should_include?(status, migration)
end
end
sort_migrations_desc(migrations)
end
def all
migrations = []
MigrationContext.instance.each do |context|
indexed_migrations = context.migrations.index_by { |m| m.version.to_s }
context.migrations_status.each do |status, version|
migration = indexed_migrations[version]
migrations << build_migration_struct(status, migration) if should_include?(status, migration)
end
end
sort_migrations_desc(migrations)
end
def find(version, database)
MigrationContext.instance.each do |context|
next unless ActualDbSchema.db_config[:database] == database
migration = find_migration_in_context(context, version)
return migration if migration
end
nil
end
def rollback(version, database)
MigrationContext.instance.each do |context|
next unless ActualDbSchema.db_config[:database] == database
if context.migrations.detect { |m| m.version.to_s == version }
context.run(:down, version.to_i)
break
end
end
end
def rollback_all
MigrationContext.instance.each(&:rollback_branches)
end
def migrate(version, database)
MigrationContext.instance.each do |context|
next unless ActualDbSchema.db_config[:database] == database
if context.migrations.detect { |m| m.version.to_s == version }
context.run(:up, version.to_i)
break
end
end
end
def broken_versions
broken = []
MigrationContext.instance.each do |context|
context.migrations_status.each do |status, version, name|
next unless name == "********** NO FILE **********"
broken << Migration.new(
status: status,
version: version.to_s,
name: name,
branch: branch_for(version),
database: ActualDbSchema.db_config[:database]
)
end
end
broken
end
def delete(version, database)
validate_broken_migration(version, database)
MigrationContext.instance.each do
next if database && ActualDbSchema.db_config[:database] != database
next if ActiveRecord::Base.connection.select_values("SELECT version FROM schema_migrations").exclude?(version)
ActiveRecord::Base.connection.execute("DELETE FROM schema_migrations WHERE version = '#{version}'")
break
end
end
def delete_all
broken_versions.each do |version|
delete(version.version, version.database)
end
end
private
def build_migration_struct(status, migration)
Migration.new(
status: status,
version: migration.version.to_s,
name: migration.name,
branch: branch_for(migration.version),
database: ActualDbSchema.db_config[:database],
filename: migration.filename,
phantom: phantom?(migration),
source: ActualDbSchema::Store.instance.source_for(migration.version)
)
end
def sort_migrations_desc(migrations)
migrations.sort_by { |migration| migration[:version].to_i }.reverse if migrations.any?
end
def phantom?(migration)
ActualDbSchema::Store.instance.stored_migration?(migration.filename)
end
def should_include?(status, migration)
migration && (status == "up" || !phantom?(migration))
end
def find_migration_in_context(context, version)
migration = context.migrations.detect { |m| m.version.to_s == version }
return unless migration
status = context.migrations_status.detect { |_s, v| v.to_s == version }&.first || "unknown"
build_migration_struct(status, migration)
end
def branch_for(version)
metadata.fetch(version.to_s, {})[:branch] || "unknown"
end
def metadata
@metadata ||= {}
@metadata[ActualDbSchema.db_config[:database]] ||= ActualDbSchema::Store.instance.read
end
def validate_broken_migration(version, database)
if database
unless broken_versions.any? { |v| v.version == version && v.database == database }
raise StandardError, "Migration is not broken for database #{database}."
end
else
raise StandardError, "Migration is not broken." unless broken_versions.any? { |v| v.version == version }
end
end
end
end
================================================
FILE: lib/actual_db_schema/migration_context.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# The class manages connections to each database and provides the appropriate migration context for each connection.
class MigrationContext
include Singleton
def each
original_config = current_config
configs.each do |db_config|
establish_connection(db_config)
yield context
end
ensure
establish_connection(original_config) if original_config
end
private
def establish_connection(db_config)
config = db_config.respond_to?(:config) ? db_config.config : db_config
ActiveRecord::Base.establish_connection(config)
end
def current_config
if ActiveRecord::Base.respond_to?(:connection_db_config)
ActiveRecord::Base.connection_db_config
else
ActiveRecord::Base.connection_config
end
end
def configs
all_configs = if ActiveRecord::Base.configurations.is_a?(Hash)
# Rails < 6.0 has a Hash in configurations
[ActiveRecord::Base.configurations[ActiveRecord::Tasks::DatabaseTasks.env]]
else
ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env)
end
filter_configs(all_configs)
end
def filter_configs(all_configs)
all_configs.reject do |db_config|
# Skip if database is in the excluded list
# Rails 6.0 uses spec_name, Rails 6.1+ uses name
db_name = if db_config.respond_to?(:name)
db_config.name.to_sym
elsif db_config.respond_to?(:spec_name)
db_config.spec_name.to_sym
else
:primary
end
ActualDbSchema.config.excluded_databases.include?(db_name)
end
end
def context
ar_version = Gem::Version.new(ActiveRecord::VERSION::STRING)
context = if ar_version >= Gem::Version.new("7.2.0") ||
(ar_version >= Gem::Version.new("7.1.0") && ar_version.prerelease?)
ActiveRecord::Base.connection_pool.migration_context
else
ActiveRecord::Base.connection.migration_context
end
context.extend(ActualDbSchema::Patches::MigrationContext)
end
end
end
================================================
FILE: lib/actual_db_schema/migration_parser.rb
================================================
# frozen_string_literal: true
require "ast"
require "prism"
module ActualDbSchema
# Parses migration files in a Rails application into a structured hash representation.
module MigrationParser
extend self
PARSER_MAPPING = {
add_column: ->(args) { parse_add_column(args) },
change_column: ->(args) { parse_change_column(args) },
remove_column: ->(args) { parse_remove_column(args) },
rename_column: ->(args) { parse_rename_column(args) },
add_index: ->(args) { parse_add_index(args) },
remove_index: ->(args) { parse_remove_index(args) },
rename_index: ->(args) { parse_rename_index(args) },
create_table: ->(args) { parse_create_table(args) },
drop_table: ->(args) { parse_drop_table(args) }
}.freeze
def parse_all_migrations(dirs)
changes_by_path = {}
handled_files = Set.new
dirs.each do |dir|
Dir["#{dir}/*.rb"].sort.each do |file|
base_name = File.basename(file)
next if handled_files.include?(base_name)
changes = parse_file(file).yield_self { |ast| find_migration_changes(ast) }
changes_by_path[file] = changes unless changes.empty?
handled_files.add(base_name)
end
end
changes_by_path
end
private
def parse_file(file_path)
Prism::Translation::Parser.parse_file(file_path)
end
def find_migration_changes(node)
return [] unless node.is_a?(Parser::AST::Node)
changes = []
if node.type == :block
return process_block_node(node)
elsif node.type == :send
changes.concat(process_send_node(node))
end
node.children.each { |child| changes.concat(find_migration_changes(child)) if child.is_a?(Parser::AST::Node) }
changes
end
def process_block_node(node)
changes = []
send_node = node.children.first
return changes unless send_node.type == :send
method_name = send_node.children[1]
return changes unless method_name == :create_table
change = parse_create_table_with_block(send_node, node)
changes << change if change
changes
end
def process_send_node(node)
changes = []
_receiver, method_name, *args = node.children
if (parser = PARSER_MAPPING[method_name])
change = parser.call(args)
changes << change if change
end
changes
end
def parse_add_column(args)
return unless args.size >= 3
{
action: :add_column,
table: sym_value(args[0]),
column: sym_value(args[1]),
type: sym_value(args[2]),
options: parse_hash(args[3])
}
end
def parse_change_column(args)
return unless args.size >= 3
{
action: :change_column,
table: sym_value(args[0]),
column: sym_value(args[1]),
type: sym_value(args[2]),
options: parse_hash(args[3])
}
end
def parse_remove_column(args)
return unless args.size >= 2
{
action: :remove_column,
table: sym_value(args[0]),
column: sym_value(args[1]),
options: parse_hash(args[2])
}
end
def parse_rename_column(args)
return unless args.size >= 3
{
action: :rename_column,
table: sym_value(args[0]),
old_column: sym_value(args[1]),
new_column: sym_value(args[2])
}
end
def parse_add_index(args)
return unless args.size >= 2
{
action: :add_index,
table: sym_value(args[0]),
columns: array_or_single_value(args[1]),
options: parse_hash(args[2])
}
end
def parse_remove_index(args)
return unless args.size >= 1
{
action: :remove_index,
table: sym_value(args[0]),
options: parse_hash(args[1])
}
end
def parse_rename_index(args)
return unless args.size >= 3
{
action: :rename_index,
table: sym_value(args[0]),
old_name: node_value(args[1]),
new_name: node_value(args[2])
}
end
def parse_create_table(args)
return unless args.size >= 1
{
action: :create_table,
table: sym_value(args[0]),
options: parse_hash(args[1])
}
end
def parse_drop_table(args)
return unless args.size >= 1
{
action: :drop_table,
table: sym_value(args[0]),
options: parse_hash(args[1])
}
end
def parse_create_table_with_block(send_node, block_node)
args = send_node.children[2..]
columns = parse_create_table_columns(block_node.children[2])
{
action: :create_table,
table: sym_value(args[0]),
options: parse_hash(args[1]),
columns: columns
}
end
def parse_create_table_columns(body_node)
return [] unless body_node
nodes = body_node.type == :begin ? body_node.children : [body_node]
nodes.map { |node| parse_column_node(node) }.compact
end
def parse_column_node(node)
return unless node.is_a?(Parser::AST::Node) && node.type == :send
method = node.children[1]
return parse_timestamps if method == :timestamps
{
column: sym_value(node.children[2]),
type: method,
options: parse_hash(node.children[3])
}
end
def parse_timestamps
[
{ column: :created_at, type: :datetime, options: { null: false } },
{ column: :updated_at, type: :datetime, options: { null: false } }
]
end
def sym_value(node)
return nil unless node && node.type == :sym
node.children.first
end
def array_or_single_value(node)
return [] unless node
if node.type == :array
node.children.map { |child| node_value(child) }
else
node_value(node)
end
end
def parse_hash(node)
return {} unless node && node.type == :hash
node.children.each_with_object({}) do |pair_node, result|
key_node, value_node = pair_node.children
key = sym_value(key_node) || node_value(key_node)
value = node_value(value_node)
result[key] = value
end
end
def node_value(node)
return nil unless node
case node.type
when :str, :sym, :int then node.children.first
when true then true
when false then false
when nil then nil
else
node.children.first
end
end
end
end
================================================
FILE: lib/actual_db_schema/multi_tenant.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Handles multi-tenancy support by switching schemas for supported databases
module MultiTenant
include ActualDbSchema::OutputFormatter
class << self
def with_schema(schema_name)
context = switch_schema(schema_name)
yield
ensure
restore_context(context)
end
private
def adapter_name
ActiveRecord::Base.connection.adapter_name
end
def switch_schema(schema_name)
case adapter_name
when /postgresql/i
switch_postgresql_schema(schema_name)
when /mysql/i
switch_mysql_schema(schema_name)
else
message = "[ActualDbSchema] Multi-tenancy not supported for adapter: #{adapter_name}. " \
"Proceeding without schema switching."
puts colorize(message, :gray)
end
end
def switch_postgresql_schema(schema_name)
old_search_path = ActiveRecord::Base.connection.schema_search_path
ActiveRecord::Base.connection.schema_search_path = schema_name
{ type: :postgresql, old_context: old_search_path }
end
def switch_mysql_schema(schema_name)
old_db = ActiveRecord::Base.connection.current_database
ActiveRecord::Base.connection.execute("USE #{ActiveRecord::Base.connection.quote_table_name(schema_name)}")
{ type: :mysql, old_context: old_db }
end
def restore_context(context)
return unless context
case context[:type]
when :postgresql
ActiveRecord::Base.connection.schema_search_path = context[:old_context] if context[:old_context]
when :mysql
return unless context[:old_context]
ActiveRecord::Base.connection.execute(
"USE #{ActiveRecord::Base.connection.quote_table_name(context[:old_context])}"
)
end
end
end
end
end
================================================
FILE: lib/actual_db_schema/output_formatter.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Provides functionality for formatting terminal output with colors
module OutputFormatter
UNICODE_COLORS = {
red: 31,
green: 32,
yellow: 33,
gray: 90
}.freeze
def colorize(text, color)
code = UNICODE_COLORS.fetch(color, 37)
"\e[#{code}m#{text}\e[0m"
end
end
end
================================================
FILE: lib/actual_db_schema/patches/migration_context.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Patches
# Add new command to roll back the phantom migrations
module MigrationContext
include ActualDbSchema::OutputFormatter
def rollback_branches(manual_mode: false)
schemas = multi_tenant_schemas&.call || []
schema_count = schemas.any? ? schemas.size : 1
rolled_back_migrations = if schemas.any?
rollback_multi_tenant(schemas, manual_mode: manual_mode)
else
rollback_branches_for_schema(manual_mode: manual_mode)
end
delete_migrations(rolled_back_migrations, schema_count)
rolled_back_migrations.any?
end
def phantom_migrations
paths = Array(migrations_paths)
current_branch_files = Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }]
current_branch_file_names = current_branch_files.map { |f| ActualDbSchema.migration_filename(f) }
migrations.reject do |migration|
current_branch_file_names.include?(ActualDbSchema.migration_filename(migration.filename))
end
end
private
def rollback_branches_for_schema(manual_mode: false, schema_name: nil, rolled_back_migrations: [])
phantom_migrations.reverse_each do |migration|
next unless status_up?(migration)
show_info_for(migration, schema_name) if manual_mode
if !manual_mode || user_wants_rollback?
migrate(migration, rolled_back_migrations, schema_name,
manual_mode: manual_mode)
end
rescue StandardError => e
handle_rollback_error(migration, e, schema_name)
end
rolled_back_migrations
end
def rollback_multi_tenant(schemas, manual_mode: false)
all_rolled_back_migrations = []
schemas.each do |schema_name|
ActualDbSchema::MultiTenant.with_schema(schema_name) do
rollback_branches_for_schema(manual_mode: manual_mode, schema_name: schema_name,
rolled_back_migrations: all_rolled_back_migrations)
end
end
all_rolled_back_migrations
end
def down_migrator_for(migration)
if ActiveRecord::Migration.current_version < 6
ActiveRecord::Migrator.new(:down, [migration], migration.version)
elsif ActiveRecord::Migration.current_version < 7.1
ActiveRecord::Migrator.new(:down, [migration], schema_migration, migration.version)
else
ActiveRecord::Migrator.new(:down, [migration], schema_migration, internal_metadata, migration.version)
end
end
def migration_files
paths = Array(migrations_paths)
current_branch_files = Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }]
other_branches_files = ActualDbSchema::Store.instance.migration_files
current_branch_versions = current_branch_files.map { |file| file.match(/(\d+)_/)[1] }
filtered_other_branches_files = other_branches_files.reject do |file|
version = file.match(/(\d+)_/)[1]
current_branch_versions.include?(version)
end
current_branch_files + filtered_other_branches_files
end
def status_up?(migration)
migrations_status.any? do |status, version|
status == "up" && version.to_s == migration.version.to_s
end
end
def user_wants_rollback?
print "\nRollback this migration? [y,n] "
answer = $stdin.gets.chomp.downcase
answer[0] == "y"
end
def show_info_for(migration, schema_name = nil)
puts colorize("\n[ActualDbSchema] A phantom migration was found and is about to be rolled back.", :gray)
puts "Please make a decision from the options below to proceed.\n\n"
puts "Schema: #{schema_name}" if schema_name
puts "Branch: #{branch_for(migration.version.to_s)}"
puts "Database: #{ActualDbSchema.db_config[:database]}"
puts "Version: #{migration.version}\n\n"
puts File.read(migration.filename)
end
def migrate(migration, rolled_back_migrations, schema_name = nil, manual_mode: false)
migration.name = extract_class_name(migration.filename)
branch = branch_for(migration.version.to_s)
message = "[ActualDbSchema]"
message += " #{schema_name}:" if schema_name
message += " Rolling back phantom migration #{migration.version} #{migration.name} " \
"(from branch: #{branch})"
puts colorize(message, :gray)
migrator = down_migrator_for(migration)
migrator.extend(ActualDbSchema::Patches::Migrator)
migrator.migrate
notify_rollback_migration(migration: migration, schema_name: schema_name, branch: branch,
manual_mode: manual_mode)
rolled_back_migrations << migration
end
def notify_rollback_migration(migration:, schema_name:, branch:, manual_mode:)
ActiveSupport::Notifications.instrument(
ActualDbSchema::Instrumentation::ROLLBACK_EVENT,
version: migration.version.to_s,
name: migration.name,
database: ActualDbSchema.db_config[:database],
schema: schema_name,
branch: branch,
manual_mode: manual_mode
)
end
def extract_class_name(filename)
content = File.read(filename)
content.match(/^class\s+([A-Za-z0-9_]+)\s+)[1]
end
def branch_for(version)
metadata.fetch(version, {})[:branch] || "unknown"
end
def metadata
@metadata ||= ActualDbSchema::Store.instance.read
end
def handle_rollback_error(migration, exception, schema_name = nil)
error_message = <<~ERROR
Error encountered during rollback:
#{cleaned_exception_message(exception.message)}
ERROR
puts colorize(error_message, :red)
ActualDbSchema.failed << FailedMigration.new(
migration: migration,
exception: exception,
branch: branch_for(migration.version.to_s),
schema: schema_name
)
end
def cleaned_exception_message(message)
patterns_to_remove = [
/^An error has occurred, all later migrations canceled:\s*/,
/^An error has occurred, this and all later migrations canceled:\s*/
]
patterns_to_remove.reduce(message.strip) { |msg, pattern| msg.gsub(pattern, "").strip }
end
def delete_migrations(migrations, schema_count)
migration_counts = migrations.each_with_object(Hash.new(0)) do |migration, hash|
hash[migration.filename] += 1
end
migrations.uniq.each do |migration|
count = migration_counts[migration.filename]
ActualDbSchema::Store.instance.delete(migration.filename) if count == schema_count
end
end
def multi_tenant_schemas
ActualDbSchema.config[:multi_tenant_schemas]
end
end
end
end
================================================
FILE: lib/actual_db_schema/patches/migration_proxy.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Patches
# Records the migration file into the tmp folder after it's been migrated
module MigrationProxy
def migrate(direction)
super(direction)
ActualDbSchema::Store.instance.write(filename) if direction == :up
end
end
end
end
================================================
FILE: lib/actual_db_schema/patches/migrator.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
module Patches
# Run only one migration that's being rolled back
module Migrator
def runnable
migration = migrations.first # there is only one migration, because we pass only one here
ran?(migration) ? [migration] : []
end
end
end
end
================================================
FILE: lib/actual_db_schema/railtie.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Integrates the ConsoleMigrations module into the Rails console.
class Railtie < ::Rails::Railtie
console do
require_relative "console_migrations"
if ActualDbSchema.config[:console_migrations_enabled]
TOPLEVEL_BINDING.receiver.extend(ActualDbSchema::ConsoleMigrations)
puts "[ActualDbSchema] ConsoleMigrations enabled. You can now use migration methods directly at the console."
end
end
end
end
================================================
FILE: lib/actual_db_schema/rollback_stats_repository.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Persists rollback events in DB.
class RollbackStatsRepository
TABLE_NAME = "actual_db_schema_rollback_events"
class << self
def record(payload)
ensure_table!
connection.execute(<<~SQL.squish)
INSERT INTO #{quoted_table}
(#{quoted_column("version")}, #{quoted_column("name")}, #{quoted_column("database")},
#{quoted_column("schema")}, #{quoted_column("branch")}, #{quoted_column("manual_mode")},
#{quoted_column("created_at")})
VALUES
(#{connection.quote(payload[:version].to_s)}, #{connection.quote(payload[:name].to_s)},
#{connection.quote(payload[:database].to_s)}, #{connection.quote((payload[:schema] || "default").to_s)},
#{connection.quote(payload[:branch].to_s)}, #{connection.quote(!!payload[:manual_mode])},
#{connection.quote(Time.current)})
SQL
end
def stats
return empty_stats unless table_exists?
{
total: total_rollbacks,
by_database: aggregate_by(:database),
by_schema: aggregate_by(:schema),
by_branch: aggregate_by(:branch)
}
end
def total_rollbacks
return 0 unless table_exists?
connection.select_value(<<~SQL.squish).to_i
SELECT COUNT(*) FROM #{quoted_table}
SQL
end
def reset!
return unless table_exists?
connection.execute("DELETE FROM #{quoted_table}")
end
private
def ensure_table!
return if table_exists?
connection.create_table(TABLE_NAME) do |t|
t.string :version, null: false
t.string :name
t.string :database, null: false
t.string :schema
t.string :branch, null: false
t.boolean :manual_mode, null: false, default: false
t.datetime :created_at, null: false
end
end
def table_exists?
connection.table_exists?(TABLE_NAME)
end
def aggregate_by(column)
return {} unless table_exists?
rows = connection.select_all(<<~SQL.squish)
SELECT #{quoted_column(column)}, COUNT(*) AS cnt
FROM #{quoted_table}
GROUP BY #{quoted_column(column)}
SQL
rows.each_with_object(Hash.new(0)) { |row, h| h[row[column.to_s].to_s] = row["cnt"].to_i }
end
def empty_stats
{
total: 0,
by_database: {},
by_schema: {},
by_branch: {}
}
end
def connection
ActiveRecord::Base.connection
end
def quoted_table
connection.quote_table_name(TABLE_NAME)
end
def quoted_column(name)
connection.quote_column_name(name)
end
end
end
end
================================================
FILE: lib/actual_db_schema/schema_diff.rb
================================================
# frozen_string_literal: true
require "tempfile"
module ActualDbSchema
# Generates a diff of schema changes between the current schema file and the
# last committed version, annotated with the migrations responsible for each change.
class SchemaDiff
include OutputFormatter
SIGN_COLORS = {
"+" => :green,
"-" => :red
}.freeze
CHANGE_PATTERNS = {
/t\.(\w+)\s+["']([^"']+)["']/ => :column,
/t\.index\s+.*name:\s*["']([^"']+)["']/ => :index,
/create_table\s+["']([^"']+)["']/ => :table
}.freeze
SQL_CHANGE_PATTERNS = {
/CREATE (?:UNIQUE\s+)?INDEX\s+["']?([^"'\s]+)["']?\s+ON\s+([\w.]+)/i => :index,
/CREATE TABLE\s+(\S+)\s+\(/i => :table,
/CREATE SEQUENCE\s+(\S+)/i => :table,
/ALTER SEQUENCE\s+(\S+)\s+OWNED BY\s+([\w.]+)/i => :table,
/ALTER TABLE\s+ONLY\s+(\S+)\s+/i => :table
}.freeze
def initialize(schema_path, migrations_path)
@schema_path = schema_path
@migrations_path = migrations_path
end
def render
if old_schema_content.nil? || old_schema_content.strip.empty?
puts colorize("Could not retrieve old schema from git.", :red)
return
end
diff_output = generate_diff(old_schema_content, new_schema_content)
process_diff_output(diff_output)
end
private
def old_schema_content
@old_schema_content ||= begin
output = `git show HEAD:#{@schema_path} 2>&1`
$CHILD_STATUS.success? ? output : nil
end
end
def new_schema_content
@new_schema_content ||= File.read(@schema_path)
end
def parsed_old_schema
@parsed_old_schema ||= parser_class.parse_string(old_schema_content.to_s)
end
def parsed_new_schema
@parsed_new_schema ||= parser_class.parse_string(new_schema_content.to_s)
end
def parser_class
structure_sql? ? StructureSqlParser : SchemaParser
end
def structure_sql?
File.extname(@schema_path) == ".sql"
end
def migration_changes
@migration_changes ||= begin
migration_dirs = [@migrations_path] + migrated_folders
MigrationParser.parse_all_migrations(migration_dirs)
end
end
def migrated_folders
ActualDbSchema::Store.instance.materialize_all
dirs = find_migrated_folders
configured_migrated_folder = ActualDbSchema.migrated_folder
relative_migrated_folder = configured_migrated_folder.to_s.sub(%r{\A#{Regexp.escape(Rails.root.to_s)}/?}, "")
dirs << relative_migrated_folder unless dirs.include?(relative_migrated_folder)
dirs.map { |dir| dir.sub(%r{\A\./}, "") }.uniq
end
def find_migrated_folders
path_parts = Pathname.new(@migrations_path).each_filename.to_a
db_index = path_parts.index("db")
return [] unless db_index
base_path = db_index.zero? ? "." : File.join(*path_parts[0...db_index])
Dir[File.join(base_path, "tmp", "migrated*")].select do |path|
File.directory?(path) && File.basename(path).match?(/^migrated(_[a-zA-Z0-9_-]+)?$/)
end
end
def generate_diff(old_content, new_content)
Tempfile.create("old_schema") do |old_file|
Tempfile.create("new_schema") do |new_file|
old_file.write(old_content)
new_file.write(new_content)
old_file.rewind
new_file.rewind
return `diff -u #{old_file.path} #{new_file.path}`
end
end
end
def process_diff_output(diff_str)
lines = diff_str.lines
current_table = nil
result_lines = []
lines.each do |line|
if (hunk_match = line.match(/^@@\s+-(\d+),(\d+)\s+\+(\d+),(\d+)\s+@@/))
current_table = find_table_in_new_schema(hunk_match[3].to_i)
elsif (ct = line.match(/create_table\s+["']([^"']+)["']/) ||
line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i) || line.match(/ALTER TABLE\s+ONLY\s+(\S+)/i))
current_table = normalize_table_name(ct[1])
end
result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line(line, current_table) : line)
end
result_lines.join
end
def handle_diff_line(line, current_table)
sign = line[0]
line_content = line[1..]
color = SIGN_COLORS[sign]
action, name = detect_action_and_name(line_content, sign, current_table)
annotation = action ? find_migrations(action, current_table, name) : []
annotated_line = annotation.any? ? annotate_line(line, annotation) : line
colorize(annotated_line, color)
end
def detect_action_and_name(line_content, sign, current_table)
patterns = structure_sql? ? SQL_CHANGE_PATTERNS : CHANGE_PATTERNS
action_map = {
column: ->(md) { [guess_action(sign, current_table, md[2]), md[2]] },
index: ->(md) { [sign == "+" ? :add_index : :remove_index, md[1]] },
table: ->(_) { [sign == "+" ? :create_table : :drop_table, nil] }
}
patterns.each do |regex, kind|
next unless (md = line_content.match(regex))
action_proc = action_map[kind]
return action_proc.call(md) if action_proc
end
if structure_sql? && current_table && (md = line_content.match(/^\s*"?(\w+)"?\s+(.+?)(?:,|\s*$)/i))
return [guess_action(sign, current_table, md[1]), md[1]]
end
[nil, nil]
end
def guess_action(sign, table, col_name)
case sign
when "+"
old_table = parsed_old_schema[table] || {}
old_table[col_name].nil? ? :add_column : :change_column
when "-"
new_table = parsed_new_schema[table] || {}
new_table[col_name].nil? ? :remove_column : :change_column
end
end
def find_table_in_new_schema(new_line_number)
current_table = nil
new_schema_content.lines[0...new_line_number].each do |line|
if (match = line.match(/create_table\s+["']([^"']+)["']/) || line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i))
current_table = normalize_table_name(match[1])
end
end
current_table
end
def find_migrations(action, table_name, col_or_index_name)
matches = []
migration_changes.each do |file_path, changes|
changes.each do |chg|
next unless (structure_sql? && index_action?(action)) || chg[:table].to_s == table_name.to_s
matches << file_path if migration_matches?(chg, action, col_or_index_name)
end
end
matches
end
def index_action?(action)
%i[add_index remove_index rename_index].include?(action)
end
def migration_matches?(chg, action, col_or_index_name)
return (chg[:action] == action) if col_or_index_name.nil?
matchers = {
rename_column: -> { rename_column_matches?(chg, action, col_or_index_name) },
rename_index: -> { rename_index_matches?(chg, action, col_or_index_name) },
add_index: -> { index_matches?(chg, action, col_or_index_name) },
remove_index: -> { index_matches?(chg, action, col_or_index_name) }
}
matchers.fetch(chg[:action], -> { column_matches?(chg, action, col_or_index_name) }).call
end
def rename_column_matches?(chg, action, col)
(action == :remove_column && chg[:old_column].to_s == col.to_s) ||
(action == :add_column && chg[:new_column].to_s == col.to_s)
end
def rename_index_matches?(chg, action, name)
(action == :remove_index && chg[:old_name] == name) ||
(action == :add_index && chg[:new_name] == name)
end
def index_matches?(chg, action, col_or_index_name)
return false unless chg[:action] == action
extract_migration_index_name(chg, chg[:table]) == col_or_index_name.to_s
end
def column_matches?(chg, action, col_name)
chg[:column] && chg[:column].to_s == col_name.to_s && chg[:action] == action
end
def extract_migration_index_name(chg, table_name)
return chg[:options][:name].to_s if chg[:options].is_a?(Hash) && chg[:options][:name]
return "" unless (columns = chg[:columns])
cols = columns.is_a?(Array) ? columns : [columns]
"index_#{table_name}_on_#{cols.join("_and_")}"
end
def annotate_line(line, migration_file_paths)
"#{line.chomp}#{colorize(" // #{migration_file_paths.join(", ")} //", :gray)}\n"
end
def normalize_table_name(table_name)
return table_name unless structure_sql? && table_name.include?(".")
table_name.split(".").last
end
end
end
================================================
FILE: lib/actual_db_schema/schema_diff_html.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Generates an HTML representation of the schema diff,
# annotated with the migrations responsible for each change.
class SchemaDiffHtml < SchemaDiff
def render_html(table_filter)
return unless old_schema_content && !old_schema_content.strip.empty?
@full_diff_html ||= generate_diff_html
filter = table_filter.to_s.strip.downcase
filter.empty? ? @full_diff_html : extract_table_section(@full_diff_html, filter)
end
private
def generate_diff_html
diff_output = generate_full_diff(old_schema_content, new_schema_content)
diff_output = new_schema_content if diff_output.strip.empty?
process_diff_output_for_html(diff_output)
end
def generate_full_diff(old_content, new_content)
Tempfile.create("old_schema") do |old_file|
Tempfile.create("new_schema") do |new_file|
old_file.write(old_content)
new_file.write(new_content)
old_file.rewind
new_file.rewind
`diff -u -U 9999999 #{old_file.path} #{new_file.path}`
end
end
end
def process_diff_output_for_html(diff_str)
current_table = nil
result_lines = []
@tables = {}
table_start = nil
block_depth = 1
diff_str.lines.each do |line|
next if skip_line?(line)
current_table, table_start, block_depth =
process_table(line, current_table, table_start, result_lines.size, block_depth)
result_lines << (%w[+ -].include?(line[0]) ? handle_diff_line_html(line, current_table) : line)
end
result_lines.join
end
def skip_line?(line)
line != "---\n" && !line.match(/^--- Name/) &&
(line.start_with?("---") || line.start_with?("+++") || line.match(/^@@/))
end
def process_table(line, current_table, table_start, table_end, block_depth)
if (ct = line.match(/create_table\s+["']([^"']+)["']/) || line.match(/CREATE TABLE\s+"?([^"\s]+)"?/i))
return [normalize_table_name(ct[1]), table_end, block_depth]
end
return [current_table, table_start, block_depth] unless current_table
block_depth += line.scan(/\bdo\b/).size unless line.match(/create_table\s+["']([^"']+)["']/)
block_depth -= line.scan(/\bend\b/).size
block_depth -= line.scan(/\);\s*$/).size
if block_depth.zero?
@tables[current_table] = { start: table_start, end: table_end }
current_table = nil
block_depth = 1
end
[current_table, table_start, block_depth]
end
def handle_diff_line_html(line, current_table)
sign = line[0]
line_content = line[1..]
color = SIGN_COLORS[sign]
action, name = detect_action_and_name(line_content, sign, current_table)
annotation = action ? find_migrations(action, current_table, name) : []
annotation.any? ? annotate_line(line, annotation, color) : colorize_html(line, color)
end
def annotate_line(line, migration_file_paths, color)
links_html = migration_file_paths.map { |path| link_to_migration(path) }.join(", ")
"#{colorize_html(line.chomp, color)}#{colorize_html(" // #{links_html} //", :gray)}\n"
end
def colorize_html(text, color)
safe = ERB::Util.html_escape(text)
case color
when :green
%(#{safe})
when :red
%(#{safe})
when :gray
%(#{text})
end
end
def link_to_migration(migration_file_path)
migration = migrations.detect { |m| File.expand_path(m.filename) == File.expand_path(migration_file_path) }
return ERB::Util.html_escape(migration_file_path) unless migration
url = "migrations/#{migration.version}?database=#{migration.database}"
"#{ERB::Util.html_escape(migration_file_path)}"
end
def migrations
@migrations ||= ActualDbSchema::Migration.instance.all
end
def extract_table_section(full_diff_html, table_name)
return unless @tables[table_name]
range = @tables[table_name]
full_diff_html.lines[range[:start]..range[:end]].join
end
end
end
================================================
FILE: lib/actual_db_schema/schema_parser.rb
================================================
# frozen_string_literal: true
require "parser/ast/processor"
require "prism"
module ActualDbSchema
# Parses the content of a `schema.rb` file into a structured hash representation.
module SchemaParser
module_function
def parse_string(schema_content)
ast = Prism::Translation::Parser.parse(schema_content)
collector = SchemaCollector.new
collector.process(ast)
collector.schema
end
# Internal class used to process the AST and collect schema information.
class SchemaCollector < Parser::AST::Processor
attr_reader :schema
def initialize
super()
@schema = {}
end
def on_block(node)
send_node, _args_node, body = *node
if create_table_call?(send_node)
table_name = extract_table_name(send_node)
columns = extract_columns(body)
@schema[table_name] = columns if table_name
end
super
end
def on_send(node)
_receiver, method_name, *args = *node
if method_name == :create_table && args.any?
table_name = extract_table_name(node)
@schema[table_name] ||= {}
end
super
end
private
def create_table_call?(node)
return false unless node.is_a?(Parser::AST::Node)
_receiver, method_name, *_args = node.children
method_name == :create_table
end
def extract_table_name(send_node)
_receiver, _method_name, table_arg, *_rest = send_node.children
return unless table_arg
case table_arg.type
when :str then table_arg.children.first
when :sym then table_arg.children.first.to_s
end
end
def extract_columns(body_node)
return {} unless body_node
children = body_node.type == :begin ? body_node.children : [body_node]
columns = {}
children.each do |expr|
col = process_column_node(expr)
columns[col[:name]] = { type: col[:type], options: col[:options] } if col && col[:name]
end
columns
end
def process_column_node(node)
return unless node.is_a?(Parser::AST::Node)
return unless node.type == :send
receiver, method_name, column_node, *args = node.children
return unless receiver && receiver.type == :lvar
return { name: "timestamps", type: :timestamps, options: {} } if method_name == :timestamps
col_name = extract_column_name(column_node)
options = extract_column_options(args)
{ name: col_name, type: method_name, options: options }
end
def extract_column_name(node)
return nil unless node.is_a?(Parser::AST::Node)
case node.type
when :str then node.children.first
when :sym then node.children.first.to_s
end
end
def extract_column_options(args)
opts = {}
args.each do |arg|
next unless arg && arg.type == :hash
opts.merge!(parse_hash(arg))
end
opts
end
def parse_hash(node)
hash = {}
return hash unless node && node.type == :hash
node.children.each do |pair|
key_node, value_node = pair.children
key = extract_key(key_node)
value = extract_literal(value_node)
hash[key] = value
end
hash
end
def extract_key(node)
return unless node.is_a?(Parser::AST::Node)
case node.type
when :sym then node.children.first
when :str then node.children.first.to_sym
end
end
def extract_literal(node)
return unless node.is_a?(Parser::AST::Node)
case node.type
when :int, :str, :sym then node.children.first
when true then true
when false then false
end
end
end
end
end
================================================
FILE: lib/actual_db_schema/store.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Stores migration sources and metadata.
class Store
include Singleton
Item = Struct.new(:version, :timestamp, :branch)
def write(filename)
adapter.write(filename)
reset_source_cache
end
def read
adapter.read
end
def migration_files
adapter.migration_files
end
def delete(filename)
adapter.delete(filename)
reset_source_cache
end
def stored_migration?(filename)
adapter.stored_migration?(filename)
end
def source_for(version)
version = version.to_s
return :db if db_versions.key?(version)
return :file if file_versions.key?(version)
:unknown
end
def materialize_all
adapter.materialize_all
end
def reset_adapter
@adapter = nil
reset_source_cache
end
private
def adapter
@adapter ||= begin
storage = ActualDbSchema.config[:migrations_storage].to_s
storage == "db" ? DbAdapter.new : FileAdapter.new
end
end
def reset_source_cache
@db_versions = nil
@file_versions = nil
end
def db_versions
@db_versions ||= begin
connection = ActiveRecord::Base.connection
return {} unless connection.table_exists?(DbAdapter::TABLE_NAME)
table = connection.quote_table_name(DbAdapter::TABLE_NAME)
connection.select_values("SELECT version FROM #{table}").each_with_object({}) do |version, acc|
acc[version.to_s] = true
end
rescue StandardError
{}
end
end
def file_versions
@file_versions ||= FileAdapter.new.read
rescue StandardError
{}
end
# Stores migrated files on the filesystem with metadata in CSV.
class FileAdapter
def write(filename)
basename = File.basename(filename)
FileUtils.mkdir_p(folder)
FileUtils.copy(filename, folder.join(basename))
record_metadata(filename)
end
def read
return {} unless File.exist?(store_file)
CSV.read(store_file).map { |line| Item.new(*line) }.index_by(&:version)
end
def migration_files
Dir["#{folder}/**/[0-9]*_*.rb"]
end
def delete(filename)
File.delete(filename) if File.exist?(filename)
end
def stored_migration?(filename)
filename.to_s.start_with?(folder.to_s)
end
def materialize_all
nil
end
private
def record_metadata(filename)
version = File.basename(filename).scan(/(\d+)_.*\.rb/).first.first
CSV.open(store_file, "a") do |csv|
csv << [
version,
Time.current.iso8601,
Git.current_branch
]
end
end
def folder
ActualDbSchema.migrated_folder
end
def store_file
folder.join("metadata.csv")
end
end
# Stores migrated files in the database.
class DbAdapter
TABLE_NAME = "actual_db_schema_migrations"
RECORD_COLUMNS = %w[version filename content branch migrated_at].freeze
def write(filename)
ensure_table!
version = extract_version(filename)
return unless version
basename = File.basename(filename)
content = File.read(filename)
upsert_record(version, basename, content, Git.current_branch, Time.current)
write_cache_file(basename, content)
end
def read
return {} unless table_exists?
rows = connection.exec_query(<<~SQL.squish)
SELECT version, migrated_at, branch
FROM #{quoted_table}
SQL
rows.map do |row|
Item.new(row["version"].to_s, row["migrated_at"], row["branch"])
end.index_by(&:version)
end
def migration_files
materialize_all
Dir["#{folder}/**/[0-9]*_*.rb"]
end
def delete(filename)
version = extract_version(filename)
return unless version
if table_exists?
connection.execute(<<~SQL.squish)
DELETE FROM #{quoted_table}
WHERE #{quoted_column("version")} = #{connection.quote(version)}
SQL
end
File.delete(filename) if File.exist?(filename)
end
def stored_migration?(filename)
filename.to_s.start_with?(folder.to_s)
end
def materialize_all
return unless table_exists?
FileUtils.mkdir_p(folder)
rows = connection.exec_query(<<~SQL.squish)
SELECT filename, content
FROM #{quoted_table}
SQL
rows.each do |row|
write_cache_file(row["filename"], row["content"])
end
end
private
def upsert_record(version, basename, content, branch, migrated_at)
attributes = record_attributes(version, basename, content, branch, migrated_at)
record_exists?(version) ? update_record(attributes) : insert_record(attributes)
end
def record_attributes(version, basename, content, branch, migrated_at)
{
version: version,
filename: basename,
content: content,
branch: branch,
migrated_at: migrated_at
}
end
def update_record(attributes)
assignments = record_columns.reject { |column| column == "version" }.map do |column|
"#{quoted_column(column)} = #{connection.quote(attributes[column.to_sym])}"
end
connection.execute(<<~SQL)
UPDATE #{quoted_table}
SET #{assignments.join(", ")}
WHERE #{quoted_column("version")} = #{connection.quote(attributes[:version])}
SQL
end
def insert_record(attributes)
columns = record_columns
values = columns.map { |column| connection.quote(attributes[column.to_sym]) }
connection.execute(<<~SQL)
INSERT INTO #{quoted_table}
(#{columns.map { |column| quoted_column(column) }.join(", ")})
VALUES
(#{values.join(", ")})
SQL
end
def record_exists?(version)
connection.select_value(<<~SQL.squish).present?
SELECT 1
FROM #{quoted_table}
WHERE #{quoted_column("version")} = #{connection.quote(version)}
LIMIT 1
SQL
end
def ensure_table!
return if table_exists?
connection.create_table(TABLE_NAME) do |t|
t.string :version, null: false
t.string :filename, null: false
t.text :content, null: false
t.string :branch
t.datetime :migrated_at, null: false
end
connection.add_index(TABLE_NAME, :version, unique: true) unless connection.index_exists?(TABLE_NAME, :version)
end
def table_exists?
connection.table_exists?(TABLE_NAME)
end
def connection
ActiveRecord::Base.connection
end
def record_columns
RECORD_COLUMNS
end
def quoted_table
connection.quote_table_name(TABLE_NAME)
end
def quoted_column(name)
connection.quote_column_name(name)
end
def folder
ActualDbSchema.migrated_folder
end
def write_cache_file(filename, content)
FileUtils.mkdir_p(folder)
path = folder.join(File.basename(filename))
return if File.exist?(path) && File.read(path) == content
File.write(path, content)
end
def extract_version(filename)
match = File.basename(filename).scan(/(\d+)_.*\.rb/).first
match&.first
end
end
end
end
================================================
FILE: lib/actual_db_schema/structure_sql_parser.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
# Parses the content of a `structure.sql` file into a structured hash representation.
module StructureSqlParser
module_function
def parse_string(sql_content)
schema = {}
table_regex = /CREATE TABLE\s+(?:"?([\w.]+)"?)\s*\((.*?)\);/m
sql_content.scan(table_regex) do |table_name, columns_section|
schema[normalize_table_name(table_name)] = parse_columns(columns_section)
end
schema
end
def parse_columns(columns_section)
columns = {}
columns_section.each_line do |line|
line.strip!
next if line.empty? || line =~ /^(CONSTRAINT|PRIMARY KEY|FOREIGN KEY)/i
match = line.match(/\A"?(?\w+)"?\s+(?\w+)(?\s*\([\d,]+\))?/i)
next unless match
col_name = match[:col]
col_type = match[:type].strip.downcase.to_sym
options = {}
columns[col_name] = { type: col_type, options: options }
end
columns
end
def normalize_table_name(table_name)
return table_name unless table_name.include?(".")
table_name.split(".").last
end
end
end
================================================
FILE: lib/actual_db_schema/version.rb
================================================
# frozen_string_literal: true
module ActualDbSchema
VERSION = "0.9.1"
end
================================================
FILE: lib/actual_db_schema.rb
================================================
# frozen_string_literal: true
require "actual_db_schema/engine"
require "active_record/migration"
require "csv"
require_relative "actual_db_schema/git"
require_relative "actual_db_schema/rollback_stats_repository"
require_relative "actual_db_schema/configuration"
require_relative "actual_db_schema/instrumentation"
require_relative "actual_db_schema/store"
require_relative "actual_db_schema/version"
require_relative "actual_db_schema/migration"
require_relative "actual_db_schema/failed_migration"
require_relative "actual_db_schema/migration_context"
require_relative "actual_db_schema/migration_parser"
require_relative "actual_db_schema/output_formatter"
require_relative "actual_db_schema/patches/migration_proxy"
require_relative "actual_db_schema/patches/migrator"
require_relative "actual_db_schema/patches/migration_context"
require_relative "actual_db_schema/git_hooks"
require_relative "actual_db_schema/multi_tenant"
require_relative "actual_db_schema/railtie"
require_relative "actual_db_schema/schema_diff"
require_relative "actual_db_schema/schema_diff_html"
require_relative "actual_db_schema/schema_parser"
require_relative "actual_db_schema/structure_sql_parser"
require_relative "actual_db_schema/commands/base"
require_relative "actual_db_schema/commands/rollback"
require_relative "actual_db_schema/commands/list"
# The main module definition
module ActualDbSchema
raise NotImplementedError, "ActualDbSchema is only supported in Rails" unless defined?(Rails)
class << self
attr_accessor :config, :failed
end
self.failed = []
self.config = Configuration.new
def self.configure
yield(config)
end
def self.migrated_folder
migrated_folders.first
end
def self.migrated_folders
return [default_migrated_folder] unless migrations_paths
Array(migrations_paths).map do |path|
if path.end_with?("db/migrate")
default_migrated_folder
else
postfix = path.split("/").last
Rails.root.join("tmp", "migrated_#{postfix}")
end
end
end
def self.default_migrated_folder
config[:migrated_folder] || Rails.root.join("tmp", "migrated")
end
def self.migrations_paths
if ActiveRecord::Base.respond_to?(:connection_db_config)
ActiveRecord::Base.connection_db_config.migrations_paths
else
ActiveRecord::Base.connection_config[:migrations_paths]
end
end
def self.db_config
if ActiveRecord::Base.respond_to?(:connection_db_config)
ActiveRecord::Base.connection_db_config.configuration_hash
else
ActiveRecord::Base.connection_config
end
end
def self.migration_filename(fullpath)
fullpath.split("/").last
end
end
ActiveRecord::MigrationProxy.prepend(ActualDbSchema::Patches::MigrationProxy)
================================================
FILE: lib/generators/actual_db_schema/templates/actual_db_schema.rb
================================================
# frozen_string_literal: true
# ActualDbSchema initializer
# Adjust the configuration as needed.
if defined?(ActualDbSchema)
ActualDbSchema.configure do |config|
# Enable the gem.
config.enabled = Rails.env.development?
# Disable automatic rollback of phantom migrations.
# config.auto_rollback_disabled = true
config.auto_rollback_disabled = ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?
# Enable the UI for managing migrations.
config.ui_enabled = Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present?
# Enable automatic phantom migration rollback on branch switch.
# config.git_hooks_enabled = true
git_hook_enabled_env = ENV["ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED"]
config.git_hooks_enabled = git_hook_enabled_env.nil? ? true : git_hook_enabled_env.present?
# If your application leverages multiple schemas for multi-tenancy, define the active schemas.
# config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] }
# Enable console migrations.
# config.console_migrations_enabled = true
config.console_migrations_enabled = ENV["ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED"].present?
# Define the migrated folder location.
# config.migrated_folder = Rails.root.join("custom", "migrated")
config.migrated_folder = Rails.root.join("tmp", "migrated")
# Choose where to store migrated files: :file or :db.
# config.migrations_storage = :db
config.migrations_storage = :file
end
# Subscribe to rollback events to persist stats (optional).
# Uncomment the following to track rollback statistics in the database:
#
# ActiveSupport::Notifications.subscribe(
# ActualDbSchema::Instrumentation::ROLLBACK_EVENT
# ) do |_name, _start, _finish, _id, payload|
# ActualDbSchema::RollbackStatsRepository.record(payload)
# end
end
================================================
FILE: lib/tasks/actual_db_schema.rake
================================================
# frozen_string_literal: true
namespace :actual_db_schema do # rubocop:disable Metrics/BlockLength
desc "Install ActualDbSchema initializer and post-checkout git hook."
task :install do
extend ActualDbSchema::OutputFormatter
initializer_path = Rails.root.join("config", "initializers", "actual_db_schema.rb")
initializer_content = File.read(
File.expand_path("../../lib/generators/actual_db_schema/templates/actual_db_schema.rb", __dir__)
)
if File.exist?(initializer_path)
puts colorize("[ActualDbSchema] An initializer already exists at #{initializer_path}.", :gray)
puts "Overwrite the existing file at #{initializer_path}? [y,n] "
answer = $stdin.gets.chomp.downcase
if answer.start_with?("y")
File.write(initializer_path, initializer_content)
puts colorize("[ActualDbSchema] Initializer updated successfully at #{initializer_path}", :green)
else
puts colorize("[ActualDbSchema] Skipped overwriting the initializer.", :yellow)
end
else
File.write(initializer_path, initializer_content)
puts colorize("[ActualDbSchema] Initializer created successfully at #{initializer_path}", :green)
end
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
desc "Install ActualDbSchema post-checkout git hook that rolls back phantom migrations when switching branches."
task :install_git_hooks do
extend ActualDbSchema::OutputFormatter
puts "Which Git hook strategy would you like to install? [1, 2, 3]"
puts " 1) Rollback phantom migrations (db:rollback_branches)"
puts " 2) Migrate up to latest (db:migrate)"
puts " 3) No hook installation (skip)"
answer = $stdin.gets.chomp
strategy =
case answer
when "1" then :rollback
when "2" then :migrate
else
:none
end
if strategy == :none
puts colorize("[ActualDbSchema] Skipping git hook installation.", :gray)
else
ActualDbSchema::GitHooks.new(strategy: strategy).install_post_checkout_hook
end
end
desc "Show the schema.rb diff annotated with the migrations that made the changes"
task :diff_schema_with_migrations, %i[schema_path migrations_path] => :environment do |_, args|
default_schema = Rails.configuration.active_record.schema_format == :sql ? "./db/structure.sql" : "./db/schema.rb"
schema_path = args[:schema_path] || default_schema
migrations_path = args[:migrations_path] || "db/migrate"
schema_diff = ActualDbSchema::SchemaDiff.new(schema_path, migrations_path)
puts schema_diff.render
end
desc "Delete broken migration versions from the database"
task :delete_broken_versions, %i[versions database] => :environment do |_, args|
extend ActualDbSchema::OutputFormatter
if args[:versions]
versions = args[:versions].split(" ").map(&:strip)
versions.each do |version|
ActualDbSchema::Migration.instance.delete(version, args[:database])
puts colorize("[ActualDbSchema] Migration #{version} was successfully deleted.", :green)
rescue StandardError => e
puts colorize("[ActualDbSchema] Error deleting version #{version}: #{e.message}", :red)
end
elsif ActualDbSchema::Migration.instance.broken_versions.empty?
puts colorize("[ActualDbSchema] No broken versions found.", :gray)
else
begin
ActualDbSchema::Migration.instance.delete_all
puts colorize("[ActualDbSchema] All broken versions were successfully deleted.", :green)
rescue StandardError => e
puts colorize("[ActualDbSchema] Error deleting all broken versions: #{e.message}", :red)
end
end
end
end
================================================
FILE: lib/tasks/db.rake
================================================
# frozen_string_literal: true
namespace :db do
desc "Rollback migrations that were run inside not a merged branch."
task rollback_branches: :load_config do
ActualDbSchema.failed = []
ActualDbSchema::MigrationContext.instance.each do |context|
ActualDbSchema::Commands::Rollback.new(context).call
end
end
namespace :rollback_branches do
desc "Manually rollback phantom migrations one by one"
task manual: :load_config do
ActualDbSchema.failed = []
ActualDbSchema::MigrationContext.instance.each do |context|
ActualDbSchema::Commands::Rollback.new(context, manual_mode: true).call
end
end
end
desc "List all phantom migrations - non-relevant migrations that were run inside not a merged branch."
task phantom_migrations: :load_config do
ActualDbSchema::MigrationContext.instance.each do |context|
ActualDbSchema::Commands::List.new(context).call
end
end
task "schema:dump" => :rollback_branches
end
================================================
FILE: lib/tasks/test.rake
================================================
# frozen_string_literal: true
namespace :test do # rubocop:disable Metrics/BlockLength
desc "Run tests with SQLite3"
task :sqlite3 do
ENV["DB_ADAPTER"] = "sqlite3"
Rake::Task["test"].invoke
Rake::Task["test"].reenable
end
desc "Run tests with PostgreSQL"
task :postgresql do
sh "docker-compose up -d postgres"
wait_for_postgres
begin
ENV["DB_ADAPTER"] = "postgresql"
Rake::Task["test"].invoke
Rake::Task["test"].reenable
ensure
sh "docker-compose down"
end
end
desc "Run tests with MySQL"
task :mysql2 do
sh "docker-compose up -d mysql"
wait_for_mysql
begin
ENV["DB_ADAPTER"] = "mysql2"
Rake::Task["test"].invoke
Rake::Task["test"].reenable
ensure
sh "docker-compose down"
end
end
desc "Run tests with all adapters (SQLite3, PostgreSQL, MySQL)"
task all: %i[sqlite3 postgresql mysql2]
def wait_for_postgres
retries = 10
begin
sh "docker-compose exec -T postgres pg_isready -U postgres"
rescue StandardError
retries -= 1
raise "PostgreSQL is not ready after several attempts." if retries < 1
sleep 2
retry
end
end
def wait_for_mysql
retries = 10
begin
sh "docker-compose exec -T mysql mysqladmin ping -h 127.0.0.1 --silent"
rescue StandardError
retries -= 1
raise "MySQL is not ready after several attempts." if retries < 1
sleep 2
retry
end
end
end
================================================
FILE: sig/actual_db_schema.rbs
================================================
module ActualDbSchema
VERSION: String
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
end
================================================
FILE: test/controllers/actual_db_schema/broken_versions_controller_db_storage_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/broken_versions_controller"
module ActualDbSchema
class BrokenVersionsControllerDbStorageTest < ActionController::TestCase
tests ActualDbSchema::BrokenVersionsController
def setup
setup_utils
configure_storage
configure_app
routes_setup
configure_views
active_record_setup
prepare_database
end
def teardown
@utils.clear_db_storage_table(TestingState.db_config)
ActualDbSchema.config[:migrations_storage] = :file
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/broken_versions" => "actual_db_schema/broken_versions#index", as: "broken_versions"
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
post "/rails/broken_version/:id/delete" => "actual_db_schema/broken_versions#delete",
as: "delete_broken_version"
post "/rails/broken_versions/delete_all" => "actual_db_schema/broken_versions#delete_all",
as: "delete_all_broken_versions"
end
ActualDbSchema::BrokenVersionsController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
def setup_utils
@utils = TestUtils.new
end
def configure_storage
ActualDbSchema.config[:migrations_storage] = :db
end
def configure_app
@app = Rails.application
Rails.logger = Logger.new($stdout)
end
def configure_views
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
end
def prepare_database
@utils.reset_database_yml(TestingState.db_config)
@utils.clear_db_storage_table(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
@utils.prepare_phantom_migrations(TestingState.db_config)
end
def delete_migrations_files
delete_primary_migrations
delete_secondary_migrations
end
def delete_primary_migrations
@utils.delete_migrations_files_for("tmp/migrated")
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
[
"tmp/migrated/20130906111511_first_primary.rb",
"tmp/migrated/20130906111512_second_primary.rb"
].each do |path|
ActualDbSchema::Store.instance.delete(@utils.app_file(path))
end
end
def delete_secondary_migrations
@utils.delete_migrations_files_for("tmp/migrated_migrate_secondary")
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
[
"tmp/migrated_migrate_secondary/20130906111514_first_secondary.rb",
"tmp/migrated_migrate_secondary/20130906111515_second_secondary.rb"
].each do |path|
ActualDbSchema::Store.instance.delete(@utils.app_file(path))
end
end
test "GET #index returns a successful response" do
delete_migrations_files
get :index
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111514"
assert_select "td", text: @utils.branch_for("20130906111514")
assert_select "td", text: @utils.secondary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111515"
assert_select "td", text: @utils.branch_for("20130906111515")
assert_select "td", text: @utils.secondary_database
end
end
end
end
test "GET #index when there are no broken versions returns a not found text" do
get :index
assert_response :success
assert_select "p", text: "No broken versions found."
end
test "POST #delete removes migration entry from the schema_migrations table" do
delete_migrations_files
version = "20130906111511"
sql = "SELECT version FROM schema_migrations WHERE version = '#{version}'"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_not_nil ActiveRecord::Base.connection.select_value(sql)
post :delete, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select "table" do |table|
assert_no_match "20130906111511", table.text
end
assert_select ".flash", text: "Migration 20130906111511 was successfully deleted."
assert_nil ActiveRecord::Base.connection.select_value(sql)
end
test "POST #delete_all removes all broken migration entries from the schema_migrations table" do
delete_migrations_files
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
post :delete_all
assert_response :redirect
get :index
assert_select "p", text: "No broken versions found."
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 0, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 0, ActiveRecord::Base.connection.select_value(sql)
end
end
end
================================================
FILE: test/controllers/actual_db_schema/broken_versions_controller_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/broken_versions_controller"
module ActualDbSchema
class BrokenVersionsControllerTest < ActionController::TestCase
def setup
@utils = TestUtils.new
@app = Rails.application
routes_setup
Rails.logger = Logger.new($stdout)
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
active_record_setup
@utils.reset_database_yml(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
@utils.prepare_phantom_migrations(TestingState.db_config)
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/broken_versions" => "actual_db_schema/broken_versions#index", as: "broken_versions"
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
post "/rails/broken_version/:id/delete" => "actual_db_schema/broken_versions#delete",
as: "delete_broken_version"
post "/rails/broken_versions/delete_all" => "actual_db_schema/broken_versions#delete_all",
as: "delete_all_broken_versions"
end
ActualDbSchema::BrokenVersionsController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
def delete_migrations_files
@utils.delete_migrations_files_for("tmp/migrated")
@utils.delete_migrations_files_for("tmp/migrated_migrate_secondary")
end
test "GET #index returns a successful response" do
delete_migrations_files
get :index
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111514"
assert_select "td", text: @utils.branch_for("20130906111514")
assert_select "td", text: @utils.secondary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111515"
assert_select "td", text: @utils.branch_for("20130906111515")
assert_select "td", text: @utils.secondary_database
end
end
end
end
test "GET #index when there are no broken versions returns a not found text" do
get :index
assert_response :success
assert_select "p", text: "No broken versions found."
end
test "POST #delete removes migration entry from the schema_migrations table" do
delete_migrations_files
version = "20130906111511"
sql = "SELECT version FROM schema_migrations WHERE version = '#{version}'"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_not_nil ActiveRecord::Base.connection.select_value(sql)
post :delete, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select "table" do |table|
assert_no_match "20130906111511", table.text
end
assert_select ".flash", text: "Migration 20130906111511 was successfully deleted."
assert_nil ActiveRecord::Base.connection.select_value(sql)
end
test "POST #delete_all removes all broken migration entries from the schema_migrations table" do
delete_migrations_files
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
post :delete_all
assert_response :redirect
get :index
assert_select "p", text: "No broken versions found."
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 0, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 0, ActiveRecord::Base.connection.select_value(sql)
end
end
end
================================================
FILE: test/controllers/actual_db_schema/migrations_controller_db_storage_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/migrations_controller"
module ActualDbSchema
class MigrationsControllerDbStorageTest < ActionController::TestCase
tests ActualDbSchema::MigrationsController
def setup
setup_utils
configure_storage
configure_app
routes_setup
configure_views
active_record_setup
prepare_database
end
def teardown
@utils.clear_db_storage_table(TestingState.db_config)
ActualDbSchema.config[:migrations_storage] = :file
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/phantom_migrations" => "actual_db_schema/phantom_migrations#index", as: "phantom_migrations"
get "/rails/broken_versions" => "actual_db_schema/broken_versions#index", as: "broken_versions"
get "/rails/schema" => "actual_db_schema/schema#index", as: "schema"
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
get "/rails/migration/:id" => "actual_db_schema/migrations#show", as: "migration"
post "/rails/migration/:id/rollback" => "actual_db_schema/migrations#rollback", as: "rollback_migration"
post "/rails/migration/:id/migrate" => "actual_db_schema/migrations#migrate", as: "migrate_migration"
end
ActualDbSchema::MigrationsController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
def setup_utils
@utils = TestUtils.new
end
def configure_storage
ActualDbSchema.config[:migrations_storage] = :db
end
def configure_app
@app = Rails.application
Rails.logger = Logger.new($stdout)
end
def configure_views
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
end
def prepare_database
@utils.reset_database_yml(TestingState.db_config)
@utils.clear_db_storage_table(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
@utils.prepare_phantom_migrations(TestingState.db_config)
end
test "GET #index returns a successful response" do
get :index
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: "FirstPrimary"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111514"
assert_select "td", text: "FirstSecondary"
assert_select "td", text: @utils.branch_for("20130906111514")
assert_select "td", text: @utils.secondary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111515"
assert_select "td", text: "SecondSecondary"
assert_select "td", text: @utils.branch_for("20130906111515")
assert_select "td", text: @utils.secondary_database
end
end
end
end
test "GET #index with search query returns filtered results" do
get :index, params: { query: "primary" }
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: "FirstPrimary"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
end
end
assert_no_match "20130906111514", @response.body
assert_no_match "20130906111515", @response.body
end
test "GET #show returns a successful response" do
get :show, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :success
assert_select "h2", text: "Migration FirstPrimary Details"
assert_select "table" do
assert_select "tr" do
assert_select "th", text: "Status"
assert_select "td", text: "up"
end
assert_select "tr" do
assert_select "th", text: "Migration ID"
assert_select "td", text: "20130906111511"
end
assert_select "tr" do
assert_select "th", text: "Database"
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "th", text: "Branch"
assert_select "td", text: @utils.branch_for("20130906111511")
end
end
assert_select "span.source-badge", text: "DB"
end
test "GET #show returns a 404 response if migration not found" do
get :show, params: { id: "nil", database: @utils.primary_database }
assert_response :not_found
end
test "POST #rollback with irreversible migration returns error message" do
%w[primary secondary].each do |prefix|
@utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
@utils.prepare_phantom_migrations(TestingState.db_config)
post :rollback, params: { id: "20130906111513", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select ".flash", text: /An error has occurred/
assert_select ".flash", text: /ActiveRecord::IrreversibleMigration/
end
test "POST #rollback changes migration status to down and hide migration with down status" do
post :rollback, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do |rows|
rows.each do |row|
assert_no_match(/down/, row.text)
end
end
end
end
assert_select ".flash", text: "Migration 20130906111511 was successfully rolled back."
end
end
end
================================================
FILE: test/controllers/actual_db_schema/migrations_controller_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/migrations_controller"
module ActualDbSchema
class MigrationsControllerTest < ActionController::TestCase
def setup
@utils = TestUtils.new
@app = Rails.application
routes_setup
Rails.logger = Logger.new($stdout)
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
active_record_setup
@utils.reset_database_yml(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
@utils.prepare_phantom_migrations(TestingState.db_config)
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/phantom_migrations" => "actual_db_schema/phantom_migrations#index", as: "phantom_migrations"
get "/rails/broken_versions" => "actual_db_schema/broken_versions#index", as: "broken_versions"
get "/rails/schema" => "actual_db_schema/schema#index", as: "schema"
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
get "/rails/migration/:id" => "actual_db_schema/migrations#show", as: "migration"
post "/rails/migration/:id/rollback" => "actual_db_schema/migrations#rollback", as: "rollback_migration"
post "/rails/migration/:id/migrate" => "actual_db_schema/migrations#migrate", as: "migrate_migration"
end
ActualDbSchema::MigrationsController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
test "GET #index returns a successful response" do
get :index
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: "FirstPrimary"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111514"
assert_select "td", text: "FirstSecondary"
assert_select "td", text: @utils.branch_for("20130906111514")
assert_select "td", text: @utils.secondary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111515"
assert_select "td", text: "SecondSecondary"
assert_select "td", text: @utils.branch_for("20130906111515")
assert_select "td", text: @utils.secondary_database
end
end
end
end
test "GET #index with search query returns filtered results" do
get :index, params: { query: "primary" }
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: "FirstPrimary"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
end
end
assert_no_match "20130906111514", @response.body
assert_no_match "20130906111515", @response.body
end
test "GET #show returns a successful response" do
get :show, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :success
assert_select "h2", text: "Migration FirstPrimary Details"
assert_select "table" do
assert_select "tr" do
assert_select "th", text: "Status"
assert_select "td", text: "up"
end
assert_select "tr" do
assert_select "th", text: "Migration ID"
assert_select "td", text: "20130906111511"
end
assert_select "tr" do
assert_select "th", text: "Database"
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "th", text: "Branch"
assert_select "td", text: @utils.branch_for("20130906111511")
end
end
assert_select "span.source-badge", text: "FILE"
end
test "GET #show returns a 404 response if migration not found" do
get :show, params: { id: "nil", database: @utils.primary_database }
assert_response :not_found
end
test "POST #rollback with irreversible migration returns error message" do
%w[primary secondary].each do |prefix|
@utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
@utils.prepare_phantom_migrations(TestingState.db_config)
post :rollback, params: { id: "20130906111513", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select ".flash", text: /An error has occurred/
assert_select ".flash", text: /ActiveRecord::IrreversibleMigration/
end
test "POST #rollback changes migration status to down and hide migration with down status" do
post :rollback, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do |rows|
rows.each do |row|
assert_no_match(/down/, row.text)
end
end
end
end
assert_select ".flash", text: "Migration 20130906111511 was successfully rolled back."
end
end
end
================================================
FILE: test/controllers/actual_db_schema/phantom_migrations_controller_db_storage_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/phantom_migrations_controller"
module ActualDbSchema
class PhantomMigrationsControllerDbStorageTest < ActionController::TestCase
tests ActualDbSchema::PhantomMigrationsController
def setup
setup_utils
configure_storage
configure_app
routes_setup
configure_views
active_record_setup
prepare_database
end
def teardown
@utils.clear_db_storage_table(TestingState.db_config)
ActualDbSchema.config[:migrations_storage] = :file
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
get "/rails/phantom_migrations" => "actual_db_schema/phantom_migrations#index", as: "phantom_migrations"
get "/rails/phantom_migration/:id" => "actual_db_schema/phantom_migrations#show", as: "phantom_migration"
post "/rails/phantom_migration/:id/rollback" => "actual_db_schema/phantom_migrations#rollback",
as: "rollback_phantom_migration"
post "/rails/phantom_migrations/rollback_all" => "actual_db_schema/phantom_migrations#rollback_all",
as: "rollback_all_phantom_migrations"
end
ActualDbSchema::PhantomMigrationsController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
def setup_utils
@utils = TestUtils.new
end
def configure_storage
ActualDbSchema.config[:migrations_storage] = :db
end
def configure_app
@app = Rails.application
Rails.logger = Logger.new($stdout)
end
def configure_views
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
end
def prepare_database
@utils.reset_database_yml(TestingState.db_config)
@utils.clear_db_storage_table(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
@utils.prepare_phantom_migrations(TestingState.db_config)
end
test "GET #index returns a successful response" do
get :index
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do |rows|
rows.each do |row|
assert_no_match(/down/, row.text)
end
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: "FirstPrimary"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111514"
assert_select "td", text: "FirstSecondary"
assert_select "td", text: @utils.branch_for("20130906111514")
assert_select "td", text: @utils.secondary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111515"
assert_select "td", text: "SecondSecondary"
assert_select "td", text: @utils.branch_for("20130906111515")
assert_select "td", text: @utils.secondary_database
end
end
end
end
test "GET #index when all migrations is down returns a not found text" do
@utils.run_migrations
get :index
assert_response :success
assert_select "p", text: "No phantom migrations found."
end
test "GET #show returns a successful response" do
get :show, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :success
assert_select "h2", text: "Phantom Migration FirstPrimary Details"
assert_select "table" do
assert_select "tr" do
assert_select "th", text: "Status"
assert_select "td", text: "up"
end
assert_select "tr" do
assert_select "th", text: "Migration ID"
assert_select "td", text: "20130906111511"
end
assert_select "tr" do
assert_select "th", text: "Database"
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "th", text: "Branch"
assert_select "td", text: @utils.branch_for("20130906111511")
end
end
assert_select "span.source-badge", text: "DB"
end
test "GET #show returns a 404 response if migration not found" do
get :show, params: { id: "nil", database: @utils.primary_database }
assert_response :not_found
end
test "POST #rollback changes migration status to down and hide migration with down status" do
post :rollback, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do |rows|
rows.each do |row|
assert_no_match(/down/, row.text)
end
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
end
end
end
assert_select ".flash", text: "Migration 20130906111511 was successfully rolled back."
end
test "POST #rollback with irreversible migration returns error message" do
%w[primary secondary].each do |prefix|
@utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
@utils.prepare_phantom_migrations(TestingState.db_config)
post :rollback, params: { id: "20130906111513", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select ".flash", text: /An error has occurred/
assert_select ".flash", text: /ActiveRecord::IrreversibleMigration/
end
test "POST #rollback_all changes all phantom migrations status to down and hide migration with down status" do
post :rollback_all
assert_response :redirect
get :index
assert_select "p", text: "No phantom migrations found."
end
end
end
================================================
FILE: test/controllers/actual_db_schema/phantom_migrations_controller_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/phantom_migrations_controller"
module ActualDbSchema
class PhantomMigrationsControllerTest < ActionController::TestCase
def setup
@utils = TestUtils.new
@app = Rails.application
routes_setup
Rails.logger = Logger.new($stdout)
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
active_record_setup
@utils.reset_database_yml(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
@utils.prepare_phantom_migrations(TestingState.db_config)
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
get "/rails/phantom_migrations" => "actual_db_schema/phantom_migrations#index", as: "phantom_migrations"
get "/rails/phantom_migration/:id" => "actual_db_schema/phantom_migrations#show", as: "phantom_migration"
post "/rails/phantom_migration/:id/rollback" => "actual_db_schema/phantom_migrations#rollback",
as: "rollback_phantom_migration"
post "/rails/phantom_migrations/rollback_all" => "actual_db_schema/phantom_migrations#rollback_all",
as: "rollback_all_phantom_migrations"
end
ActualDbSchema::PhantomMigrationsController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
test "GET #index returns a successful response" do
get :index
assert_response :success
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do |rows|
rows.each do |row|
assert_no_match(/down/, row.text)
end
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111511"
assert_select "td", text: "FirstPrimary"
assert_select "td", text: @utils.branch_for("20130906111511")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111514"
assert_select "td", text: "FirstSecondary"
assert_select "td", text: @utils.branch_for("20130906111514")
assert_select "td", text: @utils.secondary_database
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111515"
assert_select "td", text: "SecondSecondary"
assert_select "td", text: @utils.branch_for("20130906111515")
assert_select "td", text: @utils.secondary_database
end
end
end
end
test "GET #index when all migrations is down returns a not found text" do
@utils.run_migrations
get :index
assert_response :success
assert_select "p", text: "No phantom migrations found."
end
test "GET #show returns a successful response" do
get :show, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :success
assert_select "h2", text: "Phantom Migration FirstPrimary Details"
assert_select "table" do
assert_select "tr" do
assert_select "th", text: "Status"
assert_select "td", text: "up"
end
assert_select "tr" do
assert_select "th", text: "Migration ID"
assert_select "td", text: "20130906111511"
end
assert_select "tr" do
assert_select "th", text: "Database"
assert_select "td", text: @utils.primary_database
end
assert_select "tr" do
assert_select "th", text: "Branch"
assert_select "td", text: @utils.branch_for("20130906111511")
end
end
assert_select "span.source-badge", text: "FILE"
end
test "GET #show returns a 404 response if migration not found" do
get :show, params: { id: "nil", database: @utils.primary_database }
assert_response :not_found
end
test "POST #rollback changes migration status to down and hide migration with down status" do
post :rollback, params: { id: "20130906111511", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select "table" do
assert_select "tbody" do
assert_select "tr" do |rows|
rows.each do |row|
assert_no_match(/down/, row.text)
end
end
assert_select "tr" do
assert_select "td", text: "up"
assert_select "td", text: "20130906111512"
assert_select "td", text: "SecondPrimary"
assert_select "td", text: @utils.branch_for("20130906111512")
end
end
end
assert_select ".flash", text: "Migration 20130906111511 was successfully rolled back."
end
test "POST #rollback with irreversible migration returns error message" do
%w[primary secondary].each do |prefix|
@utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
@utils.prepare_phantom_migrations(TestingState.db_config)
post :rollback, params: { id: "20130906111513", database: @utils.primary_database }
assert_response :redirect
get :index
assert_select ".flash", text: /An error has occurred/
assert_select ".flash", text: /ActiveRecord::IrreversibleMigration/
end
test "POST #rollback_all changes all phantom migrations status to down and hide migration with down status" do
post :rollback_all
assert_response :redirect
get :index
assert_select "p", text: "No phantom migrations found."
end
end
end
================================================
FILE: test/controllers/actual_db_schema/schema_controller_db_storage_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/schema_controller"
module ActualDbSchema
class SchemaControllerDbStorageTest < ActionController::TestCase
tests ActualDbSchema::SchemaController
def setup
setup_utils
configure_storage
configure_app
routes_setup
configure_views
active_record_setup
prepare_database
stub_schema_diff
end
def teardown
@utils.define_migration_file("20250212084323_drop_users.rb", <<~RUBY)
class DropUsers < ActiveRecord::Migration[6.0]
def change
drop_table :users, if_exists: true
end
end
RUBY
@utils.define_migration_file("20250212084324_drop_products.rb", <<~RUBY)
class DropProducts < ActiveRecord::Migration[6.0]
def change
drop_table :products, if_exists: true
end
end
RUBY
@utils.run_migrations
@utils.clear_db_storage_table(TestingState.db_config)
ActualDbSchema.config[:migrations_storage] = :file
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
get "/rails/schema" => "actual_db_schema/schema#index", as: "schema"
end
ActualDbSchema::SchemaController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
def setup_utils
@utils = TestUtils.new
end
def configure_storage
ActualDbSchema.config[:migrations_storage] = :db
end
def configure_app
@app = Rails.application
Rails.logger = Logger.new($stdout)
end
def configure_views
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
end
def prepare_database
@utils.reset_database_yml(TestingState.db_config)
@utils.clear_db_storage_table(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
define_migrations
end
def stub_schema_diff
ActualDbSchema::SchemaDiffHtml.define_method(:initialize) do |_schema_path, _migrations_path|
@schema_path = "test/dummy_app/db/schema.rb"
@migrations_path = "test/dummy_app/db/migrate"
end
end
def define_migrations
@utils.define_migration_file("20250212084321_create_users_table.rb", <<~RUBY)
class CreateUsersTable < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.timestamps
end
end
end
RUBY
@utils.define_migration_file("20250212084322_create_products_table.rb", <<~RUBY)
class CreateProductsTable < ActiveRecord::Migration[6.0]
def change
create_table :products do |t|
t.string :name
t.timestamps
end
end
end
RUBY
@utils.run_migrations
ActualDbSchema::SchemaDiff.define_method(:old_schema_content) do
<<~RUBY
ActiveRecord::Schema[6.0].define(version: 20250212084322) do
create_table "products", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "users", force: :cascade do |t|
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
end
RUBY
end
end
test "GET #index returns a successful response" do
file_name = "20250212084325_add_surname_to_users.rb"
@utils.define_migration_file(file_name, <<~RUBY)
class AddSurnameToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :surname, :string
end
end
RUBY
@utils.run_migrations
get :index
assert_response :success
assert_select "h2", text: "Database Schema"
assert_select "div.schema-diff pre" do |pre|
assert_match(/create_table "products"/, pre.text)
assert_match(/create_table "users"/, pre.text)
assert_match(%r{\+ t\.string "surname" // #{File.join("test/dummy_app/db/migrate", file_name)} //}, pre.text)
end
end
test "GET #index with search query returns filtered results" do
get :index, params: { table: "users" }
assert_response :success
assert_select "h2", text: "Database Schema"
assert_select "div.schema-diff pre" do |pre|
assert_match(/create_table "users"/, pre.text)
refute_match(/create_table "products"/, pre.text)
end
end
end
end
================================================
FILE: test/controllers/actual_db_schema/schema_controller_test.rb
================================================
# frozen_string_literal: true
require_relative "../../test_helper"
require_relative "../../../app/controllers/actual_db_schema/schema_controller"
module ActualDbSchema
class SchemaControllerTest < ActionController::TestCase
def setup
@utils = TestUtils.new
@app = Rails.application
routes_setup
Rails.logger = Logger.new($stdout)
ActionController::Base.view_paths = [File.expand_path("../../../app/views/", __dir__)]
active_record_setup
@utils.reset_database_yml(TestingState.db_config)
@utils.cleanup(TestingState.db_config)
define_migrations
end
def teardown
@utils.define_migration_file("20250212084323_drop_users_table.rb", <<~RUBY)
class DropUsersTable < ActiveRecord::Migration[6.0]
def change
drop_table :users, if_exists: true
end
end
RUBY
@utils.define_migration_file("20250212084324_drop_products_table.rb", <<~RUBY)
class DropProductsTable < ActiveRecord::Migration[6.0]
def change
drop_table :products, if_exists: true
end
end
RUBY
@utils.run_migrations
end
def routes_setup
@routes = @app.routes
Rails.application.routes.draw do
get "/rails/migrations" => "actual_db_schema/migrations#index", as: "migrations"
get "/rails/schema" => "actual_db_schema/schema#index", as: "schema"
end
ActualDbSchema::SchemaController.include(@routes.url_helpers)
end
def active_record_setup
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
end
def define_migrations
@utils.define_migration_file("20250212084321_create_users_table.rb", <<~RUBY)
class CreateUsersTable < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.timestamps
end
end
end
RUBY
@utils.define_migration_file("20250212084322_create_products_table.rb", <<~RUBY)
class CreateProductsTable < ActiveRecord::Migration[6.0]
def change
create_table :products do |t|
t.string :name
t.timestamps
end
end
end
RUBY
@utils.run_migrations
end
def run_migration(file_name, content)
@utils.define_migration_file(file_name, content)
@utils.run_migrations
dump_schema
end
def dump_schema
return unless Rails.configuration.active_record.schema_format == :sql
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
config = if ActiveRecord::Base.respond_to?(:connection_db_config)
ActiveRecord::Base.connection_db_config
else
ActiveRecord::Base.configurations[Rails.env]
end
ActiveRecord::Tasks::DatabaseTasks.structure_dump(config, Rails.root.join("db", "structure.sql").to_s)
end
def define_schema_diff_html_methods_for_schema_rb
old_schema_content = File.read("test/dummy_app/db/schema.rb")
ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content }
ActualDbSchema::SchemaDiffHtml.define_method(:initialize) do |_schema_path, _migrations_path|
@schema_path = "test/dummy_app/db/schema.rb"
@migrations_path = "test/dummy_app/db/migrate"
end
end
def define_schema_diff_html_methods_for_structure_sql
old_schema_content = File.read("test/dummy_app/db/structure.sql")
ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content }
ActualDbSchema::SchemaDiffHtml.define_method(:initialize) do |_schema_path, _migrations_path|
@schema_path = "test/dummy_app/db/structure.sql"
@migrations_path = "test/dummy_app/db/migrate"
end
end
def add_surname_to_users_migration
<<~RUBY
class AddSurnameToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :surname, :string
end
end
RUBY
end
test "GET #index returns a successful response when using schema.rb" do
define_schema_diff_html_methods_for_schema_rb
file_name = "20250212084325_add_surname_to_users.rb"
run_migration(file_name, add_surname_to_users_migration)
get :index
assert_response :success
assert_select "h2", text: "Database Schema"
assert_select "div.schema-diff pre" do |pre|
assert_match(/create_table "products"/, pre.text)
assert_match(/create_table "users"/, pre.text)
assert_match(%r{\+ t\.string "surname" // #{File.join("test/dummy_app/db/migrate", file_name)} //}, pre.text)
end
end
test "GET #index with search query returns filtered results when using schema.rb" do
define_schema_diff_html_methods_for_schema_rb
get :index, params: { table: "users" }
assert_response :success
assert_select "h2", text: "Database Schema"
assert_select "div.schema-diff pre" do |pre|
assert_match(/create_table "users"/, pre.text)
refute_match(/create_table "products"/, pre.text)
end
end
test "GET #index returns a successful response when using structure.sql" do
skip unless TestingState.db_config["primary"]["adapter"] == "postgresql"
Rails.application.configure { config.active_record.schema_format = :sql }
dump_schema
define_schema_diff_html_methods_for_structure_sql
file_name = "20250212084325_add_surname_to_users.rb"
run_migration(file_name, add_surname_to_users_migration)
get :index
assert_response :success
assert_select "h2", text: "Database Schema"
assert_select "div.schema-diff pre" do |pre|
assert_match(/CREATE TABLE public.products/, pre.text)
assert_match(/CREATE TABLE public.users/, pre.text)
assert_match(
%r{\+ surname character varying // #{File.join("test/dummy_app/db/migrate", file_name)} //}, pre.text
)
end
end
test "GET #index with search query returns filtered results when using structure.sql" do
skip unless TestingState.db_config["primary"]["adapter"] == "postgresql"
Rails.application.configure { config.active_record.schema_format = :sql }
dump_schema
define_schema_diff_html_methods_for_structure_sql
get :index, params: { table: "users" }
assert_response :success
assert_select "h2", text: "Database Schema"
assert_select "div.schema-diff pre" do |pre|
assert_match(/CREATE TABLE public.users/, pre.text)
refute_match(/CREATE TABLE public.products/, pre.text)
end
end
end
end
================================================
FILE: test/dummy_app/config/.keep
================================================
================================================
FILE: test/dummy_app/db/migrate/.keep
================================================
================================================
FILE: test/dummy_app/db/migrate_secondary/.keep
================================================
================================================
FILE: test/dummy_app/public/404.html
================================================
================================================
FILE: test/rake_task_console_migrations_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
require_relative "../lib/actual_db_schema/console_migrations"
describe "console migrations (db storage)" do
let(:utils) { TestUtils.new }
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.clear_db_storage_table
extend ActualDbSchema::ConsoleMigrations
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.cleanup
utils.define_migration_file("20250124084321_create_users.rb", <<~RUBY)
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.string :middle_name
t.timestamps
end
add_index :users, :name, name: "index_users_on_name", unique: true
end
end
RUBY
utils.run_migrations
end
after do
utils.define_migration_file("20250124084323_drop_users.rb", <<~RUBY)
class DropUsers < ActiveRecord::Migration[6.0]
def change
drop_table :users
end
end
RUBY
utils.run_migrations
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
it "adds a column to a table" do
add_column :users, :email, :string
assert ActiveRecord::Base.connection.column_exists?(:users, :email)
end
it "removes a column from a table" do
remove_column :users, :middle_name
refute ActiveRecord::Base.connection.column_exists?(:users, :middle_name)
end
it "creates and drops a table" do
refute ActiveRecord::Base.connection.table_exists?(:categories)
create_table :categories do |t|
t.string :title
t.timestamps
end
assert ActiveRecord::Base.connection.table_exists?(:categories)
drop_table :categories
refute ActiveRecord::Base.connection.table_exists?(:categories)
end
it "changes column type" do
change_column :users, :middle_name, :text
assert_equal :text, ActiveRecord::Base.connection.columns(:users).find { |c| c.name == "middle_name" }.type
end
it "renames a column" do
rename_column :users, :name, :full_name
assert ActiveRecord::Base.connection.column_exists?(:users, :full_name)
refute ActiveRecord::Base.connection.column_exists?(:users, :name)
end
it "adds and removes an index" do
add_index :users, :middle_name, name: "index_users_on_middle_name", unique: true
assert ActiveRecord::Base.connection.index_exists?(:users, :middle_name, name: "index_users_on_middle_name")
remove_index :users, name: "index_users_on_middle_name"
refute ActiveRecord::Base.connection.index_exists?(:users, :middle_name, name: "index_users_on_middle_name")
end
it "adds and removes timestamps" do
remove_timestamps :users
refute ActiveRecord::Base.connection.column_exists?(:users, :created_at)
refute ActiveRecord::Base.connection.column_exists?(:users, :updated_at)
add_timestamps :users
assert ActiveRecord::Base.connection.column_exists?(:users, :created_at)
assert ActiveRecord::Base.connection.column_exists?(:users, :updated_at)
end
end
================================================
FILE: test/rake_task_console_migrations_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
require_relative "../lib/actual_db_schema/console_migrations"
describe "console migrations" do
let(:utils) { TestUtils.new }
before do
extend ActualDbSchema::ConsoleMigrations
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.cleanup
utils.define_migration_file("20250124084321_create_users.rb", <<~RUBY)
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.string :middle_name
t.timestamps
end
add_index :users, :name, name: "index_users_on_name", unique: true
end
end
RUBY
utils.run_migrations
end
after do
utils.define_migration_file("20250124084323_drop_users.rb", <<~RUBY)
class DropUsers < ActiveRecord::Migration[6.0]
def change
drop_table :users
end
end
RUBY
utils.run_migrations
end
it "adds a column to a table" do
add_column :users, :email, :string
assert ActiveRecord::Base.connection.column_exists?(:users, :email)
end
it "removes a column from a table" do
remove_column :users, :middle_name
refute ActiveRecord::Base.connection.column_exists?(:users, :middle_name)
end
it "creates and drops a table" do
refute ActiveRecord::Base.connection.table_exists?(:categories)
create_table :categories do |t|
t.string :title
t.timestamps
end
assert ActiveRecord::Base.connection.table_exists?(:categories)
drop_table :categories
refute ActiveRecord::Base.connection.table_exists?(:categories)
end
it "changes column type" do
change_column :users, :middle_name, :text
assert_equal :text, ActiveRecord::Base.connection.columns(:users).find { |c| c.name == "middle_name" }.type
end
it "renames a column" do
rename_column :users, :name, :full_name
assert ActiveRecord::Base.connection.column_exists?(:users, :full_name)
refute ActiveRecord::Base.connection.column_exists?(:users, :name)
end
it "adds and removes an index" do
add_index :users, :middle_name, name: "index_users_on_middle_name", unique: true
assert ActiveRecord::Base.connection.index_exists?(:users, :middle_name, name: "index_users_on_middle_name")
remove_index :users, name: "index_users_on_middle_name"
refute ActiveRecord::Base.connection.index_exists?(:users, :middle_name, name: "index_users_on_middle_name")
end
it "adds and removes timestamps" do
remove_timestamps :users
refute ActiveRecord::Base.connection.column_exists?(:users, :created_at)
refute ActiveRecord::Base.connection.column_exists?(:users, :updated_at)
add_timestamps :users
assert ActiveRecord::Base.connection.column_exists?(:users, :created_at)
assert ActiveRecord::Base.connection.column_exists?(:users, :updated_at)
end
end
================================================
FILE: test/rake_task_db_storage_full_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "single db (db storage)" do
let(:utils) { TestUtils.new }
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.clear_db_storage_table
utils.cleanup
end
describe "db:rollback_branches" do
def collect_rollback_events
events = []
subscriber = ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
yield events
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations
utils.run_migrations
assert_equal %w[20130906111511 20130906111512], utils.applied_migrations
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
assert_match(/\[ActualDbSchema\] Rolling back phantom migration/, TestingState.output)
assert_empty utils.migrated_files
end
it "emits one instrumentation event per successful rollback" do
utils.prepare_phantom_migrations
events = nil
collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end
assert_equal 2, events.size
assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
assert_equal([false, false], events.map { |event| event.payload[:manual_mode] })
assert_equal([utils.primary_database, utils.primary_database], events.map { |event| event.payload[:database] })
assert_equal([nil, nil], events.map { |event| event.payload[:schema] })
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_match(/Error encountered during rollback:/, TestingState.output)
assert_match(/ActiveRecord::IrreversibleMigration/, TestingState.output)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
it "does not emit instrumentation for failed rollbacks" do
utils.prepare_phantom_migrations
events = nil
collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end
assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
end
end
describe "with irreversible migration is the first" do
before do
utils.define_migration_file("20130906111510_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "doesn't fail fast and has formatted output" do
utils.prepare_phantom_migrations
assert_equal %i[irreversible first second], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
assert_equal(%w[20130906111510_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_match(/1 phantom migration\(s\) could not be rolled back automatically/, TestingState.output)
assert_match(/Try these steps to fix and move forward:/, TestingState.output)
assert_match(/Below are the details of the problematic migrations:/, TestingState.output)
assert_match(%r{File: tmp/migrated/20130906111510_irreversible.rb}, TestingState.output)
assert_equal %w[20130906111510_irreversible.rb], utils.migrated_files
end
end
describe "with acronyms defined" do
before do
utils.define_migration_file("20241218064344_ts360.rb", <<~RUBY)
class Ts360 < ActiveRecord::Migration[6.0]
def up
TestingState.up << :ts360
end
def down
TestingState.down << :ts360
end
end
RUBY
end
it "rolls back the phantom migrations without failing" do
utils.prepare_phantom_migrations
assert_equal %i[first second ts360], TestingState.up
assert_empty ActualDbSchema.failed
utils.define_acronym("TS360")
utils.run_migrations
assert_equal %i[ts360 second first], TestingState.down
assert_empty ActualDbSchema.failed
assert_empty utils.migrated_files
end
end
describe "with custom migrated folder" do
before do
ActualDbSchema.configure { |config| config.migrated_folder = Rails.root.join("custom", "migrated") }
end
after do
utils.remove_app_dir("custom/migrated")
ActualDbSchema.configure { |config| config.migrated_folder = nil }
end
it "creates the custom migrated folder" do
refute File.exist?(utils.app_file("custom/migrated"))
utils.run_migrations
assert File.exist?(utils.app_file("custom/migrated"))
end
it "keeps migrated migrations in the custom migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
assert_match(/\[ActualDbSchema\] Rolling back phantom migration/, TestingState.output)
assert_empty utils.migrated_files
end
end
describe "when app is not a git repository" do
it "doesn't show an error message" do
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
_out, err = capture_subprocess_io do
utils.prepare_phantom_migrations
end
refute_match("fatal: not a git repository", err)
assert_equal "unknown", ActualDbSchema::Git.current_branch
end
end
end
end
end
after do
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
describe "db:rollback_branches:manual" do
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_empty utils.migrated_files
end
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first second], TestingState.up
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(%r{ up 20130906111511 fix-bug tmp/migrated/20130906111511_first.rb}, TestingState.output)
assert_match(%r{ up 20130906111512 fix-bug tmp/migrated/20130906111512_second.rb}, TestingState.output)
end
end
end
end
================================================
FILE: test/rake_task_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "db storage" do
let(:utils) { TestUtils.new }
before do
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :db
utils.cleanup
end
after do
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
it "stores migrated files in the database" do
utils.run_migrations
conn = ActiveRecord::Base.connection
assert conn.table_exists?("actual_db_schema_migrations")
rows = conn.select_all("select version, filename from actual_db_schema_migrations").to_a
versions = rows.map { |row| row["version"] }.sort
assert_equal %w[20130906111511 20130906111512], versions
end
it "rolls back phantom migrations and clears stored records" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
rows = ActiveRecord::Base.connection.select_all("select version from actual_db_schema_migrations").to_a
assert_empty rows
end
it "materializes migration files from the database" do
utils.run_migrations
FileUtils.rm_rf(utils.app_file("tmp/migrated"))
ActualDbSchema::Store.instance.materialize_all
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
end
================================================
FILE: test/rake_task_delete_broken_versions_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "actual_db_schema:delete_broken_versions (db storage)" do
let(:utils) do
TestUtils.new(
migrations_path: ["db/migrate", "db/migrate_secondary"],
migrated_path: ["tmp/migrated", "tmp/migrated_migrate_secondary"]
)
end
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.reset_database_yml(TestingState.db_config)
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
utils.cleanup(TestingState.db_config)
utils.clear_db_storage_table(TestingState.db_config)
utils.run_migrations
end
def delete_migration_files
remove_primary_migration_files
remove_secondary_migration_files
delete_primary_storage_entries
delete_secondary_storage_entries
end
def remove_primary_migration_files
utils.remove_app_dir(Rails.root.join("db", "migrate", "20130906111511_first_primary.rb"))
utils.remove_app_dir(Rails.root.join("tmp", "migrated", "20130906111511_first_primary.rb"))
end
def remove_secondary_migration_files
utils.remove_app_dir(Rails.root.join("db", "migrate_secondary", "20130906111514_first_secondary.rb"))
utils.remove_app_dir(Rails.root.join("tmp", "migrated_migrate_secondary", "20130906111514_first_secondary.rb"))
end
def delete_primary_storage_entries
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
ActualDbSchema::Store.instance.delete(utils.app_file("tmp/migrated/20130906111511_first_primary.rb"))
end
def delete_secondary_storage_entries
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
secondary_path = "tmp/migrated_migrate_secondary/20130906111514_first_secondary.rb"
ActualDbSchema::Store.instance.delete(utils.app_file(secondary_path))
end
describe "when versions are provided" do
before { delete_migration_files }
it "deletes the specified broken migrations" do
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
Rake::Task["actual_db_schema:delete_broken_versions"].invoke("20130906111511 20130906111514")
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] Migration 20130906111511 was successfully deleted./, TestingState.output)
assert_match(/\[ActualDbSchema\] Migration 20130906111514 was successfully deleted./, TestingState.output)
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
end
it "deletes broken migrations only from the given database when specified" do
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
Rake::Task["actual_db_schema:delete_broken_versions"]
.invoke("20130906111511 20130906111514", TestingState.db_config["primary"]["database"])
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] Migration 20130906111511 was successfully deleted./, TestingState.output)
assert_match(
/\[ActualDbSchema\] Error deleting version 20130906111514: Migration is not broken for database #{TestingState.db_config["primary"]["database"]}./, # rubocop:disable Layout/LineLength
TestingState.output
)
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
end
it "prints an error message when the passed version is not broken" do
Rake::Task["actual_db_schema:delete_broken_versions"].invoke("20130906111512")
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(
/\[ActualDbSchema\] Error deleting version 20130906111512: Migration is not broken./, TestingState.output
)
end
end
describe "when no versions are provided" do
before { delete_migration_files }
it "deletes all broken migrations" do
delete_migration_files
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
Rake::Task["actual_db_schema:delete_broken_versions"].invoke
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] All broken versions were successfully deleted./, TestingState.output)
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
end
it "prints an error message if there is an error during deletion" do
original_delete_all = ActualDbSchema::Migration.instance_method(:delete_all)
ActualDbSchema::Migration.define_method(:delete_all) do
raise StandardError, "Deletion error"
end
Rake::Task["actual_db_schema:delete_broken_versions"].invoke
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] Error deleting all broken versions: Deletion error/, TestingState.output)
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActualDbSchema::Migration.define_method(:delete_all, original_delete_all)
end
end
describe "when there are no broken versions" do
it "prints a message indicating no broken versions found" do
Rake::Task["actual_db_schema:delete_broken_versions"].invoke
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/No broken versions found/, TestingState.output)
end
end
after do
utils.clear_db_storage_table(TestingState.db_config)
ActualDbSchema.config[:migrations_storage] = :file
end
end
================================================
FILE: test/rake_task_delete_broken_versions_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "actual_db_schema:delete_broken_versions" do
let(:utils) do
TestUtils.new(
migrations_path: ["db/migrate", "db/migrate_secondary"],
migrated_path: ["tmp/migrated", "tmp/migrated_migrate_secondary"]
)
end
before do
utils.reset_database_yml(TestingState.db_config)
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
utils.cleanup(TestingState.db_config)
utils.run_migrations
end
def delete_migration_files
utils.remove_app_dir(Rails.root.join("db", "migrate", "20130906111511_first_primary.rb"))
utils.remove_app_dir(Rails.root.join("db", "migrate_secondary", "20130906111514_first_secondary.rb"))
utils.remove_app_dir(Rails.root.join("tmp", "migrated", "20130906111511_first_primary.rb"))
utils.remove_app_dir(Rails.root.join("tmp", "migrated_migrate_secondary", "20130906111514_first_secondary.rb"))
end
describe "when versions are provided" do
before { delete_migration_files }
it "deletes the specified broken migrations" do
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
Rake::Task["actual_db_schema:delete_broken_versions"].invoke("20130906111511 20130906111514")
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] Migration 20130906111511 was successfully deleted./, TestingState.output)
assert_match(/\[ActualDbSchema\] Migration 20130906111514 was successfully deleted./, TestingState.output)
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
end
it "deletes broken migrations only from the given database when specified" do
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
Rake::Task["actual_db_schema:delete_broken_versions"]
.invoke("20130906111511 20130906111514", TestingState.db_config["primary"]["database"])
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] Migration 20130906111511 was successfully deleted./, TestingState.output)
assert_match(
/\[ActualDbSchema\] Error deleting version 20130906111514: Migration is not broken for database #{TestingState.db_config["primary"]["database"]}./, # rubocop:disable Layout/LineLength
TestingState.output
)
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
end
it "prints an error message when the passed version is not broken" do
Rake::Task["actual_db_schema:delete_broken_versions"].invoke("20130906111512")
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(
/\[ActualDbSchema\] Error deleting version 20130906111512: Migration is not broken./, TestingState.output
)
end
end
describe "when no versions are provided" do
before { delete_migration_files }
it "deletes all broken migrations" do
delete_migration_files
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
Rake::Task["actual_db_schema:delete_broken_versions"].invoke
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] All broken versions were successfully deleted./, TestingState.output)
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 1, ActiveRecord::Base.connection.select_value(sql)
end
it "prints an error message if there is an error during deletion" do
original_delete_all = ActualDbSchema::Migration.instance_method(:delete_all)
ActualDbSchema::Migration.define_method(:delete_all) do
raise StandardError, "Deletion error"
end
Rake::Task["actual_db_schema:delete_broken_versions"].invoke
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/\[ActualDbSchema\] Error deleting all broken versions: Deletion error/, TestingState.output)
sql = "SELECT COUNT(*) FROM schema_migrations"
ActiveRecord::Base.establish_connection(TestingState.db_config["primary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActiveRecord::Base.establish_connection(TestingState.db_config["secondary"])
assert_equal 2, ActiveRecord::Base.connection.select_value(sql)
ActualDbSchema::Migration.define_method(:delete_all, original_delete_all)
end
end
describe "when there are no broken versions" do
it "prints a message indicating no broken versions found" do
Rake::Task["actual_db_schema:delete_broken_versions"].invoke
Rake::Task["actual_db_schema:delete_broken_versions"].reenable
assert_match(/No broken versions found/, TestingState.output)
end
end
end
================================================
FILE: test/rake_task_git_hooks_install_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "actual_db_schema:install_git_hooks (db storage)" do
let(:utils) { TestUtils.new }
let(:hook_path) { utils.app_file(".git/hooks/post-checkout") }
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.clear_db_storage_table
FileUtils.mkdir_p(utils.app_file(".git/hooks"))
Rails.application.load_tasks
ActualDbSchema.config[:git_hooks_enabled] = true
end
after do
FileUtils.rm_rf(utils.app_file(".git/hooks"))
Rake::Task["actual_db_schema:install_git_hooks"].reenable
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
describe "when .git/hooks directory is missing" do
before do
FileUtils.rm_rf(utils.app_file(".git/hooks"))
end
it "does not attempt installation and shows an error message" do
utils.simulate_input("1") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
refute File.exist?(hook_path)
assert_match(
%r{\[ActualDbSchema\] .git/hooks directory not found. Please ensure this is a Git repository.},
TestingState.output
)
end
end
describe "when user chooses rollback" do
it "installs the rollback snippet in post-checkout" do
refute File.exist?(hook_path)
utils.simulate_input("1") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
assert File.exist?(hook_path)
contents = File.read(hook_path)
assert_includes(contents, "db:rollback_branches")
refute_includes(contents, "db:migrate")
end
end
describe "when user chooses migrate" do
it "installs the migrate snippet in post-checkout" do
refute File.exist?(hook_path)
utils.simulate_input("2") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
assert File.exist?(hook_path)
contents = File.read(hook_path)
assert_includes(contents, "db:migrate")
refute_includes(contents, "db:rollback_branches")
end
end
describe "when user chooses none" do
it "skips installing the post-checkout hook" do
refute File.exist?(hook_path)
utils.simulate_input("3") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
refute File.exist?(hook_path)
assert_match(/\[ActualDbSchema\] Skipping git hook installation\./, TestingState.output)
end
end
describe "when post-checkout hook already exists" do
before do
File.write(hook_path, "#!/usr/bin/env bash\n# Existing content\n")
end
it "appends content if user decides to overwrite" do
utils.simulate_input("1\ny") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
contents = File.read(hook_path)
assert_includes(contents, "db:rollback_branches")
assert_includes(contents, "# Existing content")
end
it "does not change file and shows manual instructions if user declines overwrite" do
utils.simulate_input("2\nn") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
contents = File.read(hook_path)
refute_includes(contents, "db:migrate")
assert_includes(contents, "# Existing content")
assert_match(/\[ActualDbSchema\] You can follow these steps to manually install the hook/, TestingState.output)
end
end
describe "existing post-checkout hook with markers" do
before do
File.write(
hook_path,
<<~BASH
#!/usr/bin/env bash
echo "some existing code"
# >>> BEGIN ACTUAL_DB_SCHEMA
echo "old snippet"
# <<< END ACTUAL_DB_SCHEMA
BASH
)
end
it "updates the snippet if markers exist" do
utils.simulate_input("2") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
contents = File.read(hook_path)
refute_includes(contents, "old snippet")
assert_includes(contents, "db:migrate")
assert_includes(contents, "some existing code")
end
end
end
================================================
FILE: test/rake_task_git_hooks_install_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "actual_db_schema:install_git_hooks" do
let(:utils) { TestUtils.new }
let(:hook_path) { utils.app_file(".git/hooks/post-checkout") }
before do
FileUtils.mkdir_p(utils.app_file(".git/hooks"))
Rails.application.load_tasks
ActualDbSchema.config[:git_hooks_enabled] = true
end
after do
FileUtils.rm_rf(utils.app_file(".git/hooks"))
Rake::Task["actual_db_schema:install_git_hooks"].reenable
end
describe "when .git/hooks directory is missing" do
before do
FileUtils.rm_rf(utils.app_file(".git/hooks"))
end
it "does not attempt installation and shows an error message" do
utils.simulate_input("1") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
refute File.exist?(hook_path)
assert_match(
%r{\[ActualDbSchema\] .git/hooks directory not found. Please ensure this is a Git repository.},
TestingState.output
)
end
end
describe "when user chooses rollback" do
it "installs the rollback snippet in post-checkout" do
refute File.exist?(hook_path)
utils.simulate_input("1") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
assert File.exist?(hook_path)
contents = File.read(hook_path)
assert_includes(contents, "db:rollback_branches")
refute_includes(contents, "db:migrate")
end
end
describe "when user chooses migrate" do
it "installs the migrate snippet in post-checkout" do
refute File.exist?(hook_path)
utils.simulate_input("2") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
assert File.exist?(hook_path)
contents = File.read(hook_path)
assert_includes(contents, "db:migrate")
refute_includes(contents, "db:rollback_branches")
end
end
describe "when user chooses none" do
it "skips installing the post-checkout hook" do
refute File.exist?(hook_path)
utils.simulate_input("3") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
refute File.exist?(hook_path)
assert_match(/\[ActualDbSchema\] Skipping git hook installation\./, TestingState.output)
end
end
describe "when post-checkout hook already exists" do
before do
File.write(hook_path, "#!/usr/bin/env bash\n# Existing content\n")
end
it "appends content if user decides to overwrite" do
utils.simulate_input("1\ny") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
contents = File.read(hook_path)
assert_includes(contents, "db:rollback_branches")
assert_includes(contents, "# Existing content")
end
it "does not change file and shows manual instructions if user declines overwrite" do
utils.simulate_input("2\nn") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
contents = File.read(hook_path)
refute_includes(contents, "db:migrate")
assert_includes(contents, "# Existing content")
assert_match(/\[ActualDbSchema\] You can follow these steps to manually install the hook/, TestingState.output)
end
end
describe "existing post-checkout hook with markers" do
before do
File.write(
hook_path,
<<~BASH
#!/usr/bin/env bash
echo "some existing code"
# >>> BEGIN ACTUAL_DB_SCHEMA
echo "old snippet"
# <<< END ACTUAL_DB_SCHEMA
BASH
)
end
it "updates the snippet if markers exist" do
utils.simulate_input("2") do
Rake::Task["actual_db_schema:install_git_hooks"].invoke
end
contents = File.read(hook_path)
refute_includes(contents, "old snippet")
assert_includes(contents, "db:migrate")
assert_includes(contents, "some existing code")
end
end
end
================================================
FILE: test/rake_task_multi_tenant_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "multi-tenant db support (db storage)" do
let(:utils) { TestUtils.new }
before do
skip "Skipping multi-tenant tests for sqlite3" if TestingState.db_config["primary"]["adapter"] == "sqlite3"
ActualDbSchema.config[:migrations_storage] = :db
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.clear_db_storage_table
if ActiveRecord::Base.connection.adapter_name =~ /postgresql/i
ActiveRecord::Base.connection.execute("CREATE SCHEMA IF NOT EXISTS tenant1")
ActualDbSchema.config[:multi_tenant_schemas] = -> { %w[public tenant1] }
elsif ActiveRecord::Base.connection.adapter_name =~ /mysql/i
ActiveRecord::Base.connection.execute("CREATE DATABASE IF NOT EXISTS tenant1")
ActualDbSchema.config[:multi_tenant_schemas] = -> { [TestingState.db_config["primary"]["database"], "tenant1"] }
end
utils.cleanup
end
after do
if ActiveRecord::Base.connection.adapter_name =~ /postgresql/i
ActiveRecord::Base.connection.execute("DROP SCHEMA IF EXISTS tenant1 CASCADE")
elsif ActiveRecord::Base.connection.adapter_name =~ /mysql/i
ActiveRecord::Base.connection.execute("DROP DATABASE IF EXISTS tenant1")
end
ActualDbSchema.config[:multi_tenant_schemas] = nil
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
describe "db:rollback_branches" do
it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations
utils.run_migrations
assert_equal %w[20130906111511 20130906111512], utils.applied_migrations
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back phantom migrations both in public (or primary) schema and tenant1" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first second first], TestingState.down
primary_schema = {
"postgresql" => "public",
"mysql2" => TestingState.db_config["primary"]["database"]
}.fetch(TestingState.db_config["primary"]["adapter"])
assert_match(/\[ActualDbSchema\] #{primary_schema}: Rolling back phantom migration/, TestingState.output)
assert_match(/\[ActualDbSchema\] tenant1: Rolling back phantom migration/, TestingState.output)
end
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible.rb 20130906111513_irreversible.rb], failed)
assert_includes utils.migrated_files, "20130906111513_irreversible.rb"
end
end
describe "db:rollback_branches:manual" do
it "rolls back phantom migrations both in public (or primary) schema and tenant1" do
utils.prepare_phantom_migrations
assert_equal %i[first second first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first second first], TestingState.down
assert_empty utils.migrated_files
end
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first second first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first second first second], TestingState.up
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first second first], TestingState.down
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible.rb 20130906111513_irreversible.rb], failed)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(%r{ up 20130906111511 fix-bug tmp/migrated/20130906111511_first.rb}, TestingState.output)
assert_match(%r{ up 20130906111512 fix-bug tmp/migrated/20130906111512_second.rb}, TestingState.output)
end
end
end
end
================================================
FILE: test/rake_task_multi_tenant_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "multi-tenant db support" do
let(:utils) { TestUtils.new }
before do
skip "Skipping multi-tenant tests for sqlite3" if TestingState.db_config["primary"]["adapter"] == "sqlite3"
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
if ActiveRecord::Base.connection.adapter_name =~ /postgresql/i
ActiveRecord::Base.connection.execute("CREATE SCHEMA IF NOT EXISTS tenant1")
ActualDbSchema.config[:multi_tenant_schemas] = -> { %w[public tenant1] }
elsif ActiveRecord::Base.connection.adapter_name =~ /mysql/i
ActiveRecord::Base.connection.execute("CREATE DATABASE IF NOT EXISTS tenant1")
ActualDbSchema.config[:multi_tenant_schemas] = -> { [TestingState.db_config["primary"]["database"], "tenant1"] }
end
utils.cleanup
end
after do
if ActiveRecord::Base.connection.adapter_name =~ /postgresql/i
ActiveRecord::Base.connection.execute("DROP SCHEMA IF EXISTS tenant1 CASCADE")
elsif ActiveRecord::Base.connection.adapter_name =~ /mysql/i
ActiveRecord::Base.connection.execute("DROP DATABASE IF EXISTS tenant1")
end
ActualDbSchema.config[:multi_tenant_schemas] = nil
end
describe "db:rollback_branches" do
it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations
utils.run_migrations
assert_equal %w[20130906111511 20130906111512], utils.applied_migrations
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back phantom migrations both in public (or primary) schema and tenant1" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first second first], TestingState.down
primary_schema = {
"postgresql" => "public",
"mysql2" => TestingState.db_config["primary"]["database"]
}.fetch(TestingState.db_config["primary"]["adapter"])
assert_match(/\[ActualDbSchema\] #{primary_schema}: Rolling back phantom migration/, TestingState.output)
assert_match(/\[ActualDbSchema\] tenant1: Rolling back phantom migration/, TestingState.output)
assert_empty utils.migrated_files
end
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible.rb 20130906111513_irreversible.rb], failed)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
describe "db:rollback_branches:manual" do
it "rolls back phantom migrations both in public (or primary) schema and tenant1" do
utils.prepare_phantom_migrations
assert_equal %i[first second first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first second first], TestingState.down
assert_empty utils.migrated_files
end
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first second first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first second first second], TestingState.up
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first second first], TestingState.down
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible.rb 20130906111513_irreversible.rb], failed)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(%r{ up 20130906111511 fix-bug tmp/migrated/20130906111511_first.rb}, TestingState.output)
assert_match(%r{ up 20130906111512 fix-bug tmp/migrated/20130906111512_second.rb}, TestingState.output)
end
end
end
end
================================================
FILE: test/rake_task_schema_diff_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "actual_db_schema:diff_schema_with_migrations (db storage)" do
let(:utils) { TestUtils.new }
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.clear_db_storage_table
utils.cleanup
utils.define_migration_file("20250124084321_create_users.rb", <<~RUBY)
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.string :middle_name
t.timestamps
end
add_index :users, :name, name: "index_users_on_name", unique: true
end
end
RUBY
utils.define_migration_file("20250124084322_create_products.rb", <<~RUBY)
class CreateProducts < ActiveRecord::Migration[6.0]
def change
create_table :products do |t|
t.string :name
t.decimal :price, precision: 10, scale: 2
t.timestamps
end
end
end
RUBY
utils.run_migrations
ActualDbSchema::SchemaDiff.define_method(:old_schema_content) do
<<~RUBY
ActiveRecord::Schema[6.0].define(version: 20250124084322) do
create_table "products", force: :cascade do |t|
t.string "name"
t.decimal "price", precision: 10, scale: 2
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "users", force: :cascade do |t|
t.string "name"
t.string "middle_name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_users_on_name", unique: true
end
end
RUBY
end
end
after do
utils.define_migration_file("20250124084323_drop_users.rb", <<~RUBY)
class DropUsers < ActiveRecord::Migration[6.0]
def change
drop_table :users
end
end
RUBY
utils.define_migration_file("20250124084324_drop_products.rb", <<~RUBY)
class DropProducts < ActiveRecord::Migration[6.0]
def change
drop_table :products, if_exists: true
end
end
RUBY
utils.run_migrations
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
def invoke_rake_task
Rake::Task["actual_db_schema:diff_schema_with_migrations"].invoke(
"test/dummy_app/db/schema.rb", "test/dummy_app/db/migrate"
)
Rake::Task["actual_db_schema:diff_schema_with_migrations"].reenable
end
def migration_path(file_name)
File.join("test/dummy_app/db/migrate", file_name)
end
it "annotates adding a column" do
file_name = "20250124084325_add_surname_to_users.rb"
utils.define_migration_file(file_name, <<~RUBY)
class AddSurnameToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :surname, :string
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{\+ t\.string "surname" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates removing a column" do
file_name = "20250124084326_remove_middle_name_from_users.rb"
utils.define_migration_file(file_name, <<~RUBY)
class RemoveMiddleNameFromUsers < ActiveRecord::Migration[6.0]
def change
remove_column :users, :middle_name
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{- t\.string "middle_name" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates changing a column" do
file_name = "20250124084327_change_price_precision_in_products.rb"
utils.define_migration_file(file_name, <<~RUBY)
class ChangePricePrecisionInProducts < ActiveRecord::Migration[6.0]
def change
change_column :products, :price, :decimal, precision: 15, scale: 2
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{- t\.decimal "price", precision: 10, scale: 2 // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ t\.decimal "price", precision: 15, scale: 2 // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates renaming a column" do
file_name = "20250124084328_rename_name_to_full_name_in_users.rb"
utils.define_migration_file(file_name, <<~RUBY)
class RenameNameToFullNameInUsers < ActiveRecord::Migration[6.0]
def change
rename_column :users, :name, :full_name
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{- t\.string "name" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ t\.string "full_name" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates adding an index" do
file_name = "20250124084329_add_index_on_users_middle_name.rb"
utils.define_migration_file(file_name, <<~RUBY)
class AddIndexOnUsersMiddleName < ActiveRecord::Migration[6.0]
def change
add_index :users, :middle_name, name: "index_users_on_middle_name", unique: true
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{\+ t\.index \["middle_name"\], name: "index_users_on_middle_name", unique: true // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates removing an index" do
file_name = "20250124084330_remove_index_on_users_name.rb"
utils.define_migration_file(file_name, <<~RUBY)
class RemoveIndexOnUsersName < ActiveRecord::Migration[6.0]
def change
remove_index :users, name: "index_users_on_name"
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates renaming an index" do
file_name = "20250124084331_rename_index_on_users_name.rb"
utils.define_migration_file(file_name, <<~RUBY)
class RenameIndexOnUsersName < ActiveRecord::Migration[6.0]
def change
rename_index :users, "index_users_on_name", "index_users_on_user_name"
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ t\.index \["name"\], name: "index_users_on_user_name", unique: true // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates creating a new table" do
file_name = "20250124084332_create_categories.rb"
utils.define_migration_file(file_name, <<~RUBY)
class CreateCategories < ActiveRecord::Migration[6.0]
def change
create_table :categories do |t|
t.string :title
t.timestamps
end
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{\+ create_table "categories", force: :cascade do |t| // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
utils.define_migration_file("20250124084333_drop_categories.rb", <<~RUBY)
class DropCategories < ActiveRecord::Migration[6.0]
def change
drop_table :categories
end
end
RUBY
utils.run_migrations
end
it "annotates dropping a table" do
file_name = "20250124084334_drop_products_table.rb"
utils.define_migration_file(file_name, <<~RUBY)
class DropProductsTable < ActiveRecord::Migration[6.0]
def change
drop_table :products
end
end
RUBY
utils.run_migrations
invoke_rake_task
assert_match(
%r{- create_table "products", force: :cascade do |t| // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "processes phantom migrations from tmp/migrated folders" do
file_name = "20250124084335_phantom.rb"
utils.define_migration_file(file_name, <<~RUBY)
class Phantom < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def up
TestingState.up << :phantom
end
def down
add_column :users, :email, :string
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
utils.run_migrations
utils.remove_app_dir(Rails.root.join("db", "migrate", file_name))
utils.run_migrations
invoke_rake_task
assert_match(
%r{\+ t\.string "email" // #{File.join("test/dummy_app/tmp/migrated", file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
end
================================================
FILE: test/rake_task_schema_diff_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "actual_db_schema:diff_schema_with_migrations" do
let(:utils) { TestUtils.new }
before do
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.cleanup
utils.define_migration_file("20250124084321_create_users.rb", <<~RUBY)
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :name
t.string :middle_name
t.timestamps
end
add_index :users, :name, name: "index_users_on_name", unique: true
end
end
RUBY
utils.define_migration_file("20250124084322_create_products.rb", <<~RUBY)
class CreateProducts < ActiveRecord::Migration[6.0]
def change
create_table :products do |t|
t.string :name
t.decimal :price, precision: 10, scale: 2
t.timestamps
end
end
end
RUBY
utils.run_migrations
end
after do
utils.define_migration_file("20250124084323_drop_users.rb", <<~RUBY)
class DropUsers < ActiveRecord::Migration[6.0]
def change
drop_table :users
end
end
RUBY
utils.define_migration_file("20250124084324_drop_products.rb", <<~RUBY)
class DropProducts < ActiveRecord::Migration[6.0]
def change
drop_table :products, if_exists: true
end
end
RUBY
utils.run_migrations
end
def migration_path(file_name)
File.join("test/dummy_app/db/migrate", file_name)
end
def invoke_rake_task(schema_path)
Rake::Task["actual_db_schema:diff_schema_with_migrations"].invoke(schema_path, "test/dummy_app/db/migrate")
Rake::Task["actual_db_schema:diff_schema_with_migrations"].reenable
end
def run_migration(file_name, content)
utils.define_migration_file(file_name, content)
utils.run_migrations
dump_schema
end
def dump_schema
return unless Rails.configuration.active_record.schema_format == :sql
config = if ActiveRecord::Base.respond_to?(:connection_db_config)
ActiveRecord::Base.connection_db_config
else
ActiveRecord::Base.configurations[Rails.env]
end
ActiveRecord::Tasks::DatabaseTasks.structure_dump(config, Rails.root.join("db", "structure.sql").to_s)
end
describe "when using schema.rb" do
before do
old_schema_content = File.read("test/dummy_app/db/schema.rb")
ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content }
end
it "annotates adding a column" do
file_name = "20250124084325_add_surname_to_users.rb"
run_migration(file_name, add_surname_to_users_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{\+ t\.string "surname" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates removing a column" do
file_name = "20250124084326_remove_middle_name_from_users.rb"
run_migration(file_name, remove_middle_name_from_users_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{- t\.string "middle_name" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates changing a column" do
file_name = "20250124084327_change_price_precision_in_products.rb"
run_migration(file_name, change_price_precision_in_products_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{- t\.decimal "price", precision: 10, scale: 2 // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ t\.decimal "price", precision: 15, scale: 2 // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates renaming a column" do
file_name = "20250124084328_rename_name_to_full_name_in_users.rb"
run_migration(file_name, rename_name_to_full_name_in_users_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{- t\.string "name" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ t\.string "full_name" // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates adding an index" do
file_name = "20250124084329_add_index_on_users_middle_name.rb"
run_migration(file_name, add_index_on_users_middle_name_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{\+ t\.index \["middle_name"\], name: "index_users_on_middle_name", unique: true // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates removing an index" do
file_name = "20250124084330_remove_index_on_users_name.rb"
run_migration(file_name, remove_index_on_users_name_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates renaming an index" do
file_name = "20250124084331_rename_index_on_users_name.rb"
run_migration(file_name, rename_index_on_users_name_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{- t\.index \["name"\], name: "index_users_on_name", unique: true // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ t\.index \["name"\], name: "index_users_on_user_name", unique: true // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates creating a new table" do
file_name = "20250124084332_create_categories.rb"
run_migration(file_name, create_categories_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{\+ create_table "categories", force: :cascade do |t| // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
run_migration("20250124084333_drop_categories.rb", drop_categories_migration)
end
it "annotates dropping a table" do
file_name = "20250124084334_drop_products_table.rb"
run_migration(file_name, drop_products_table_migration)
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{- create_table "products", force: :cascade do |t| // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "processes phantom migrations from tmp/migrated folders" do
file_name = "20250124084335_phantom.rb"
run_migration(file_name, phantom_migration)
utils.remove_app_dir(Rails.root.join("db", "migrate", file_name))
utils.run_migrations
invoke_rake_task("test/dummy_app/db/schema.rb")
assert_match(
%r{\+ t\.string "email" // #{File.join("test/dummy_app/tmp/migrated", file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
end
describe "when using structure.sql" do
before do
skip unless TestingState.db_config["primary"]["adapter"] == "postgresql"
Rails.application.configure { config.active_record.schema_format = :sql }
dump_schema
old_schema_content = File.read("test/dummy_app/db/structure.sql")
ActualDbSchema::SchemaDiff.define_method(:old_schema_content) { old_schema_content }
end
after do
Rails.application.configure { config.active_record.schema_format = :ruby }
end
it "annotates adding a column" do
file_name = "20250124084325_add_surname_to_users.rb"
run_migration(file_name, add_surname_to_users_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{\+ surname character varying // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates removing a column" do
file_name = "20250124084326_remove_middle_name_from_users.rb"
run_migration(file_name, remove_middle_name_from_users_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{- middle_name character varying, // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates changing a column" do
file_name = "20250124084327_change_price_precision_in_products.rb"
run_migration(file_name, change_price_precision_in_products_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{- price numeric\(10,2\), // #{migration_path(file_name)} //}, TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ price numeric\(15,2\), // #{migration_path(file_name)} //}, TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates renaming a column" do
file_name = "20250124084328_rename_name_to_full_name_in_users.rb"
run_migration(file_name, rename_name_to_full_name_in_users_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{- name character varying, // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ full_name character varying, // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates adding an index" do
file_name = "20250124084329_add_index_on_users_middle_name.rb"
run_migration(file_name, add_index_on_users_middle_name_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{\+CREATE UNIQUE INDEX index_users_on_middle_name ON public.users USING btree \(middle_name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates removing an index" do
file_name = "20250124084330_remove_index_on_users_name.rb"
run_migration(file_name, remove_index_on_users_name_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{-CREATE UNIQUE INDEX index_users_on_name ON public.users USING btree \(name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates renaming an index" do
file_name = "20250124084331_rename_index_on_users_name.rb"
run_migration(file_name, rename_index_on_users_name_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{-CREATE UNIQUE INDEX index_users_on_name ON public.users USING btree \(name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+CREATE UNIQUE INDEX index_users_on_user_name ON public.users USING btree \(name\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "annotates creating a new table" do
file_name = "20250124084332_create_categories.rb"
run_migration(file_name, create_categories_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{\+CREATE TABLE public.categories \( // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+CREATE SEQUENCE public.categories_id_seq // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ALTER SEQUENCE public.categories_id_seq OWNED BY public.categories.id; // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ALTER TABLE ONLY public.categories ALTER COLUMN id SET DEFAULT nextval\('public.categories_id_seq'::regclass\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{\+ALTER TABLE ONLY public.categories // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
run_migration("20250124084333_drop_categories.rb", drop_categories_migration)
end
it "annotates dropping a table" do
file_name = "20250124084334_drop_products_table.rb"
run_migration(file_name, drop_products_table_migration)
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{-CREATE TABLE public.products \( // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{-CREATE SEQUENCE public.products_id_seq // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{-ALTER SEQUENCE public.products_id_seq OWNED BY public.products.id; // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{-ALTER TABLE ONLY public.products ALTER COLUMN id SET DEFAULT nextval\('public.products_id_seq'::regclass\); // #{migration_path(file_name)} //}, # rubocop:disable Layout/LineLength
TestingState.output.gsub(/\e\[\d+m/, "")
)
assert_match(
%r{-ALTER TABLE ONLY public.products // #{migration_path(file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
it "processes phantom migrations from tmp/migrated folders" do
file_name = "20250124084335_phantom.rb"
run_migration(file_name, phantom_migration)
utils.remove_app_dir(Rails.root.join("db", "migrate", file_name))
utils.run_migrations
dump_schema
invoke_rake_task("test/dummy_app/db/structure.sql")
assert_match(
%r{\+ email character varying // #{File.join("test/dummy_app/tmp/migrated", file_name)} //},
TestingState.output.gsub(/\e\[\d+m/, "")
)
end
end
def add_surname_to_users_migration
<<~RUBY
class AddSurnameToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :surname, :string
end
end
RUBY
end
def remove_middle_name_from_users_migration
<<~RUBY
class RemoveMiddleNameFromUsers < ActiveRecord::Migration[6.0]
def change
remove_column :users, :middle_name
end
end
RUBY
end
def change_price_precision_in_products_migration
<<~RUBY
class ChangePricePrecisionInProducts < ActiveRecord::Migration[6.0]
def change
change_column :products, :price, :decimal, precision: 15, scale: 2
end
end
RUBY
end
def rename_name_to_full_name_in_users_migration
<<~RUBY
class RenameNameToFullNameInUsers < ActiveRecord::Migration[6.0]
def change
rename_column :users, :name, :full_name
end
end
RUBY
end
def add_index_on_users_middle_name_migration
<<~RUBY
class AddIndexOnUsersMiddleName < ActiveRecord::Migration[6.0]
def change
add_index :users, :middle_name, name: "index_users_on_middle_name", unique: true
end
end
RUBY
end
def remove_index_on_users_name_migration
<<~RUBY
class RemoveIndexOnUsersName < ActiveRecord::Migration[6.0]
def change
remove_index :users, name: "index_users_on_name"
end
end
RUBY
end
def rename_index_on_users_name_migration
<<~RUBY
class RenameIndexOnUsersName < ActiveRecord::Migration[6.0]
def change
rename_index :users, "index_users_on_name", "index_users_on_user_name"
end
end
RUBY
end
def create_categories_migration
<<~RUBY
class CreateCategories < ActiveRecord::Migration[6.0]
def change
create_table :categories do |t|
t.string :title
t.timestamps
end
end
end
RUBY
end
def drop_categories_migration
<<~RUBY
class DropCategories < ActiveRecord::Migration[6.0]
def change
drop_table :categories
end
end
RUBY
end
def drop_products_table_migration
<<~RUBY
class DropProductsTable < ActiveRecord::Migration[6.0]
def change
drop_table :products
end
end
RUBY
end
def phantom_migration
<<~RUBY
class Phantom < ActiveRecord::Migration[6.0]
disable_ddl_transaction!
def up
TestingState.up << :phantom
end
def down
add_column :users, :email, :string
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
end
================================================
FILE: test/rake_task_secondary_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "second db support (db storage)" do
let(:utils) do
TestUtils.new(migrations_path: "db/migrate_secondary", migrated_path: "tmp/migrated_migrate_secondary")
end
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.reset_database_yml(TestingState.db_config["secondary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["secondary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["secondary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["secondary"])
utils.clear_db_storage_table
utils.cleanup
end
describe "db:rollback_branches" do
it "creates the tmp/migrated_migrate_secondary folder" do
refute File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations
utils.run_migrations
assert_equal %w[20130906111511 20130906111512], utils.applied_migrations
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
assert_empty utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:rollback_branches:manual" do
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_empty utils.migrated_files
end
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first second], TestingState.up
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(
%r{ up 20130906111511 fix-bug tmp/migrated_migrate_secondary/20130906111511_first.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111512 fix-bug tmp/migrated_migrate_secondary/20130906111512_second.rb},
TestingState.output
)
end
end
end
after do
utils.clear_db_storage_table
ActualDbSchema.config[:migrations_storage] = :file
end
end
================================================
FILE: test/rake_task_secondary_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "second db support" do
let(:utils) do
TestUtils.new(migrations_path: "db/migrate_secondary", migrated_path: "tmp/migrated_migrate_secondary")
end
before do
utils.reset_database_yml(TestingState.db_config["secondary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["secondary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["secondary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["secondary"])
utils.cleanup
end
describe "db:rollback_branches" do
it "creates the tmp/migrated_migrate_secondary folder" do
refute File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations
utils.run_migrations
assert_equal %w[20130906111511 20130906111512], utils.applied_migrations
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
assert_empty utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:rollback_branches:manual" do
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_empty utils.migrated_files
end
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first second], TestingState.up
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(
%r{ up 20130906111511 fix-bug tmp/migrated_migrate_secondary/20130906111511_first.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111512 fix-bug tmp/migrated_migrate_secondary/20130906111512_second.rb},
TestingState.output
)
end
end
end
end
================================================
FILE: test/rake_task_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "single db" do
let(:utils) { TestUtils.new }
before do
utils.reset_database_yml(TestingState.db_config["primary"])
ActiveRecord::Base.configurations = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config["primary"] }
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
utils.cleanup
end
describe "db:rollback_branches" do
def collect_rollback_events
events = []
subscriber = ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |*args|
events << ActiveSupport::Notifications::Event.new(*args)
end
yield events
ensure
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
end
it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations
utils.run_migrations
assert_equal %w[20130906111511 20130906111512], utils.applied_migrations
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
assert_match(/\[ActualDbSchema\] Rolling back phantom migration/, TestingState.output)
assert_empty utils.migrated_files
end
it "emits one instrumentation event per successful rollback" do
utils.prepare_phantom_migrations
events = nil
collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end
assert_equal 2, events.size
assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
assert_equal([false, false], events.map { |event| event.payload[:manual_mode] })
assert_equal([utils.primary_database, utils.primary_database], events.map { |event| event.payload[:database] })
assert_equal([nil, nil], events.map { |event| event.payload[:schema] })
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_match(/Error encountered during rollback:/, TestingState.output)
assert_match(/ActiveRecord::IrreversibleMigration/, TestingState.output)
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
it "does not emit instrumentation for failed rollbacks" do
utils.prepare_phantom_migrations
events = nil
collect_rollback_events do |captured_events|
utils.run_migrations
events = captured_events
end
assert_equal(%w[20130906111512 20130906111511], events.map { |event| event.payload[:version] })
end
end
describe "with irreversible migration is the first" do
before do
utils.define_migration_file("20130906111510_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "doesn't fail fast and has formatted output" do
utils.prepare_phantom_migrations
assert_equal %i[irreversible first second], TestingState.up
assert_empty ActualDbSchema.failed
utils.run_migrations
assert_equal(%w[20130906111510_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_match(/1 phantom migration\(s\) could not be rolled back automatically/, TestingState.output)
assert_match(/Try these steps to fix and move forward:/, TestingState.output)
assert_match(/Below are the details of the problematic migrations:/, TestingState.output)
assert_match(%r{File: tmp/migrated/20130906111510_irreversible.rb}, TestingState.output)
assert_equal %w[20130906111510_irreversible.rb], utils.migrated_files
end
end
describe "with acronyms defined" do
before do
utils.define_migration_file("20241218064344_ts360.rb", <<~RUBY)
class Ts360 < ActiveRecord::Migration[6.0]
def up
TestingState.up << :ts360
end
def down
TestingState.down << :ts360
end
end
RUBY
end
it "rolls back the phantom migrations without failing" do
utils.prepare_phantom_migrations
assert_equal %i[first second ts360], TestingState.up
assert_empty ActualDbSchema.failed
utils.define_acronym("TS360")
utils.run_migrations
assert_equal %i[ts360 second first], TestingState.down
assert_empty ActualDbSchema.failed
assert_empty utils.migrated_files
end
end
describe "with custom migrated folder" do
before do
ActualDbSchema.configure { |config| config.migrated_folder = Rails.root.join("custom", "migrated") }
end
after do
utils.remove_app_dir("custom/migrated")
ActualDbSchema.configure { |config| config.migrated_folder = nil }
end
it "creates the custom migrated folder" do
refute File.exist?(utils.app_file("custom/migrated"))
utils.run_migrations
assert File.exist?(utils.app_file("custom/migrated"))
end
it "keeps migrated migrations in the custom migrated folder" do
utils.run_migrations
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second first], TestingState.down
assert_match(/\[ActualDbSchema\] Rolling back phantom migration/, TestingState.output)
assert_empty utils.migrated_files
end
end
describe "when app is not a git repository" do
it "doesn't show an error message" do
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
_out, err = capture_subprocess_io do
utils.prepare_phantom_migrations
end
refute_match("fatal: not a git repository", err)
assert_equal "unknown", ActualDbSchema::Git.current_branch
end
end
end
end
end
describe "db:rollback_branches:manual" do
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_empty utils.migrated_files
end
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first second], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first second], TestingState.up
assert_equal %w[20130906111511_first.rb 20130906111512_second.rb], utils.migrated_files
end
describe "with irreversible migration" do
before do
utils.define_migration_file("20130906111513_irreversible.rb", <<~RUBY)
class Irreversible < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations
assert_equal %i[first second irreversible], TestingState.up
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_equal %i[second first], TestingState.down
assert_equal(%w[20130906111513_irreversible.rb], ActualDbSchema.failed.map { |m| File.basename(m.filename) })
assert_equal %w[20130906111513_irreversible.rb], utils.migrated_files
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(%r{ up 20130906111511 fix-bug tmp/migrated/20130906111511_first.rb}, TestingState.output)
assert_match(%r{ up 20130906111512 fix-bug tmp/migrated/20130906111512_second.rb}, TestingState.output)
end
end
end
end
================================================
FILE: test/rake_tasks_all_databases_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "multipe db support (db storage)" do
let(:utils) do
TestUtils.new(
migrations_path: ["db/migrate", "db/migrate_secondary"],
migrated_path: ["tmp/migrated", "tmp/migrated_migrate_secondary"]
)
end
before do
ActualDbSchema.config[:migrations_storage] = :db
utils.reset_database_yml(TestingState.db_config)
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
utils.cleanup(TestingState.db_config)
utils.clear_db_storage_table(TestingState.db_config)
end
describe "db:rollback_branches" do
it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
refute File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated"))
assert File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations(TestingState.db_config)
utils.run_migrations
assert_equal(
%w[20130906111511 20130906111512 20130906111514 20130906111515],
utils.applied_migrations(TestingState.db_config)
)
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal(
%w[
20130906111511_first_primary.rb
20130906111512_second_primary.rb
20130906111514_first_secondary.rb
20130906111515_second_secondary.rb
],
utils.migrated_files(TestingState.db_config)
)
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations(TestingState.db_config)
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second_primary first_primary second_secondary first_secondary], TestingState.down
assert_empty utils.migrated_files(TestingState.db_config)
end
describe "with irreversible migration" do
before do
%w[primary secondary].each do |prefix|
utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations(TestingState.db_config)
assert_equal(
%i[first_primary second_primary irreversible_primary irreversible_secondary first_secondary second_secondary],
TestingState.up
)
assert_empty ActualDbSchema.failed
utils.run_migrations
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb], failed)
assert_equal(
%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb],
utils.migrated_files(TestingState.db_config)
)
end
end
end
describe "db:rollback_branches:manual" do
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first_primary second_primary first_secondary second_secondary], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first_primary second_primary first_secondary second_secondary], TestingState.up
assert_equal(
%w[
20130906111511_first_primary.rb
20130906111512_second_primary.rb
20130906111514_first_secondary.rb
20130906111515_second_secondary.rb
],
utils.migrated_files(TestingState.db_config)
)
end
describe "with irreversible migration" do
before do
%w[primary secondary].each do |prefix|
utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations(TestingState.db_config)
assert_equal(
%i[first_primary second_primary irreversible_primary irreversible_secondary first_secondary second_secondary],
TestingState.up
)
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb], failed)
assert_equal(
%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb],
utils.migrated_files(TestingState.db_config)
)
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations(TestingState.db_config)
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(
%r{ up 20130906111511 fix-bug tmp/migrated/20130906111511_first_primary.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111512 fix-bug tmp/migrated/20130906111512_second_primary.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111514 fix-bug tmp/migrated_migrate_secondary/20130906111514_first_secondary.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111515 fix-bug tmp/migrated_migrate_secondary/20130906111515_second_secondary.rb},
TestingState.output
)
end
end
end
after do
utils.clear_db_storage_table(TestingState.db_config)
ActualDbSchema.config[:migrations_storage] = :file
end
end
================================================
FILE: test/rake_tasks_all_databases_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "multipe db support" do
let(:utils) do
TestUtils.new(
migrations_path: ["db/migrate", "db/migrate_secondary"],
migrated_path: ["tmp/migrated", "tmp/migrated_migrate_secondary"]
)
end
before do
utils.reset_database_yml(TestingState.db_config)
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
utils.cleanup(TestingState.db_config)
end
describe "db:rollback_branches" do
it "creates the tmp/migrated folder" do
refute File.exist?(utils.app_file("tmp/migrated"))
refute File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
utils.run_migrations
assert File.exist?(utils.app_file("tmp/migrated"))
assert File.exist?(utils.app_file("tmp/migrated_migrate_secondary"))
end
it "migrates the migrations" do
assert_empty utils.applied_migrations(TestingState.db_config)
utils.run_migrations
assert_equal(
%w[20130906111511 20130906111512 20130906111514 20130906111515],
utils.applied_migrations(TestingState.db_config)
)
end
it "keeps migrated migrations in tmp/migrated folder" do
utils.run_migrations
assert_equal(
%w[
20130906111511_first_primary.rb
20130906111512_second_primary.rb
20130906111514_first_secondary.rb
20130906111515_second_secondary.rb
],
utils.migrated_files(TestingState.db_config)
)
end
it "rolls back the migrations in the reversed order" do
utils.prepare_phantom_migrations(TestingState.db_config)
assert_empty TestingState.down
utils.run_migrations
assert_equal %i[second_primary first_primary second_secondary first_secondary], TestingState.down
assert_empty utils.migrated_files(TestingState.db_config)
end
describe "with irreversible migration" do
before do
%w[primary secondary].each do |prefix|
utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations(TestingState.db_config)
assert_equal(
%i[first_primary second_primary irreversible_primary irreversible_secondary first_secondary second_secondary],
TestingState.up
)
assert_empty ActualDbSchema.failed
utils.run_migrations
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb], failed)
assert_equal(
%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb],
utils.migrated_files(TestingState.db_config)
)
end
end
end
describe "db:rollback_branches:manual" do
it "skips migrations if the input is 'n'" do
utils.prepare_phantom_migrations
assert_equal %i[first_primary second_primary first_secondary second_secondary], TestingState.up
assert_empty TestingState.down
assert_empty ActualDbSchema.failed
utils.simulate_input("n") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
assert_empty TestingState.down
assert_equal %i[first_primary second_primary first_secondary second_secondary], TestingState.up
assert_equal(
%w[
20130906111511_first_primary.rb
20130906111512_second_primary.rb
20130906111514_first_secondary.rb
20130906111515_second_secondary.rb
],
utils.migrated_files(TestingState.db_config)
)
end
describe "with irreversible migration" do
before do
%w[primary secondary].each do |prefix|
utils.define_migration_file("20130906111513_irreversible_#{prefix}.rb", <<~RUBY, prefix: prefix)
class Irreversible#{prefix.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :irreversible_#{prefix}
end
def down
raise ActiveRecord::IrreversibleMigration
end
end
RUBY
end
end
it "keeps track of the irreversible migrations" do
utils.prepare_phantom_migrations(TestingState.db_config)
assert_equal(
%i[first_primary second_primary irreversible_primary irreversible_secondary first_secondary second_secondary],
TestingState.up
)
assert_empty ActualDbSchema.failed
utils.simulate_input("y") do
Rake::Task["db:rollback_branches:manual"].invoke
Rake::Task["db:rollback_branches:manual"].reenable
end
failed = ActualDbSchema.failed.map { |m| File.basename(m.filename) }
assert_equal(%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb], failed)
assert_equal(
%w[20130906111513_irreversible_primary.rb 20130906111513_irreversible_secondary.rb],
utils.migrated_files(TestingState.db_config)
)
end
end
end
describe "db:phantom_migrations" do
it "shows the list of phantom migrations" do
ActualDbSchema::Git.stub(:current_branch, "fix-bug") do
utils.prepare_phantom_migrations(TestingState.db_config)
Rake::Task["db:phantom_migrations"].invoke
Rake::Task["db:phantom_migrations"].reenable
assert_match(/ Status Migration ID Branch Migration File/, TestingState.output)
assert_match(/---------------------------------------------------/, TestingState.output)
assert_match(
%r{ up 20130906111511 fix-bug tmp/migrated/20130906111511_first_primary.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111512 fix-bug tmp/migrated/20130906111512_second_primary.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111514 fix-bug tmp/migrated_migrate_secondary/20130906111514_first_secondary.rb},
TestingState.output
)
assert_match(
%r{ up 20130906111515 fix-bug tmp/migrated_migrate_secondary/20130906111515_second_secondary.rb},
TestingState.output
)
end
end
end
end
================================================
FILE: test/support/test_utils.rb
================================================
# frozen_string_literal: true
class TestUtils
attr_accessor :migrations_paths, :migrated_paths, :migration_timestamps, :connection_prefix
MIGRATED_PATHS = {
primary: "tmp/migrated",
secondary: "tmp/migrated_migrate_secondary"
}.freeze
MIGRATION_PATHS = {
primary: "db/migrate",
secondary: "db/migrate_secondary"
}.freeze
def initialize(migrations_path: "db/migrate", migrated_path: "tmp/migrated")
@migrations_paths = Array.wrap(migrations_path)
@migrated_paths = Array.wrap(migrated_path)
@migration_timestamps = %w[
20130906111511
20130906111512
20130906111514
20130906111515
]
end
def app_file(path)
Rails.application.config.root.join(path)
end
def remove_app_dir(name)
FileUtils.rm_rf(app_file(name))
end
def run_migrations
schemas = ActualDbSchema.config[:multi_tenant_schemas]&.call
if schemas
schemas.each { |schema| ActualDbSchema::MultiTenant.with_schema(schema) { run_migration_tasks } }
else
run_migration_tasks
end
end
def applied_migrations(db_config = nil)
if db_config
db_config.each_with_object([]) do |(_, config), acc|
ActiveRecord::Base.establish_connection(**config)
acc.concat(applied_migrations_call)
end
else
applied_migrations_call
end
end
def simulate_input(input)
$stdin = StringIO.new("#{([input] * 999).join("\n")}\n")
yield
end
def delete_migrations_files(prefix_name = nil)
path = MIGRATION_PATHS.fetch(prefix_name&.to_sym, migrations_paths.first)
delete_migrations_files_for(path)
end
def delete_migrations_files_for(path)
Dir.glob(app_file("#{path}/**/*.rb")).each do |file|
remove_app_dir(file)
end
end
def define_migration_file(filename, content, prefix: nil)
path =
case prefix
when "primary"
"db/migrate"
when "secondary"
"db/migrate_secondary"
when nil
migrations_paths.first
else
raise "Unknown prefix: #{prefix}"
end
File.write(app_file("#{path}/#{filename}"), content, mode: "w")
end
def define_migrations(prefix_name = nil)
prefix = "_#{prefix_name}" if prefix_name
raise "No migration timestamps left" if @migration_timestamps.size < 2
{
first: "#{@migration_timestamps.shift}_first#{prefix}.rb",
second: "#{@migration_timestamps.shift}_second#{prefix}.rb"
}.each do |key, file_name|
define_migration_file(file_name, <<~RUBY, prefix: prefix_name)
class #{key.to_s.camelize}#{prefix_name.to_s.camelize} < ActiveRecord::Migration[6.0]
def up
TestingState.up << :#{key}#{prefix}
end
def down
TestingState.down << :#{key}#{prefix}
end
end
RUBY
end
end
def reset_database_yml(db_config)
database_yml_path = Rails.root.join("config", "database.yml")
cleanup_config_files(db_config)
File.open(database_yml_path, "w") do |file|
file.write({
"test" => db_config
}.to_yaml)
end
end
def cleanup_config_files(db_config)
is_multi_db = db_config.is_a?(Hash) && db_config.key?("primary")
configs = is_multi_db ? db_config.values : [db_config]
configs.each do |config|
database_path = Rails.root.join(config["database"])
File.delete(database_path) if File.exist?(database_path)
end
end
def prepare_phantom_migrations(db_config = nil)
run_migrations
if db_config
db_config.each_key do |name|
delete_migrations_files(name) # simulate switching branches
end
else
delete_migrations_files
end
end
def cleanup(db_config = nil)
reset_acronyms
if db_config
db_config.each do |name, c|
ActiveRecord::Base.establish_connection(**c)
cleanup_call(name)
end
else
cleanup_call
end
TestingState.reset
end
def clear_db_storage_table(db_config = nil)
if db_config
db_config.each do |(_, config)|
ActiveRecord::Base.establish_connection(**config)
drop_db_storage_table
end
else
drop_db_storage_table
end
end
def drop_db_storage_table
return unless ActiveRecord::Base.connected?
conn = ActiveRecord::Base.connection
conn.drop_table("actual_db_schema_migrations") if conn.table_exists?("actual_db_schema_migrations")
end
def migrated_files(db_config = nil)
if db_config
db_config.each_with_object([]) do |(prefix_name, config), acc|
ActiveRecord::Base.establish_connection(**config)
acc.concat(migrated_files_call(prefix_name))
end
else
migrated_files_call
end
end
def branch_for(version)
metadata.fetch(version.to_s, {})[:branch]
end
def define_acronym(acronym)
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym acronym
end
end
def reset_acronyms
inflections = ActiveSupport::Inflector.inflections(:en)
return unless inflections.respond_to?(:acronyms)
inflections.acronyms.clear
inflections.send(:define_acronym_regex_patterns)
rescue NoMethodError
nil
end
def primary_database
TestingState.db_config["primary"]["database"]
end
def secondary_database
TestingState.db_config["secondary"]["database"]
end
private
def run_migration_tasks
if ActualDbSchema.config[:multi_tenant_schemas].present?
ActiveRecord::MigrationContext.new(Rails.root.join("db/migrate"), schema_migration_class).migrate
end
Rake::Task["db:migrate"].invoke
Rake::Task["db:migrate"].reenable
Rake::Task["db:rollback_branches"].reenable
end
def cleanup_call(prefix_name = nil)
delete_migrations_files(prefix_name)
create_schema_migration_table
clear_schema_call
remove_app_dir(MIGRATED_PATHS.fetch(prefix_name&.to_sym, migrated_paths.first))
define_migrations(prefix_name)
Rake::Task.clear
Rails.application.load_tasks
end
def create_schema_migration_table
schema_migration_class.create_table
end
def schema_migration_class
if ActiveRecord::SchemaMigration.respond_to?(:create_table)
ActiveRecord::SchemaMigration
else
ar_version = Gem::Version.new(ActiveRecord::VERSION::STRING)
if ar_version >= Gem::Version.new("7.2.0") || (ar_version >= Gem::Version.new("7.1.0") && ar_version.prerelease?)
ActiveRecord::SchemaMigration.new(ActiveRecord::Base.connection_pool)
else
ActiveRecord::SchemaMigration.new(ActiveRecord::Base.connection)
end
end
end
def migrated_files_call(prefix_name = nil)
migrated_path = ActualDbSchema.config[:migrated_folder].presence || migrated_paths.first
path = MIGRATED_PATHS.fetch(prefix_name&.to_sym, migrated_path.to_s)
Dir.glob(app_file("#{path}/*.rb")).map { |f| File.basename(f) }.sort
end
def clear_schema_call
run_sql("delete from schema_migrations")
end
def applied_migrations_call
run_sql("select version from schema_migrations order by version").map do |row|
row.is_a?(Hash) ? row["version"] : row[0]
end
end
def run_sql(sql)
ActiveRecord::Base.connection.execute(sql)
end
def metadata
ActualDbSchema::Store.instance.read
end
end
================================================
FILE: test/test_actual_db_schema.rb
================================================
# frozen_string_literal: true
require "test_helper"
class TestActualDbSchema < Minitest::Test
def test_that_it_has_a_version_number
refute_nil ::ActualDbSchema::VERSION
end
end
================================================
FILE: test/test_actual_db_schema_db_storage_test.rb
================================================
# frozen_string_literal: true
require "test_helper"
class TestActualDbSchemaDbStorage < Minitest::Test
def setup
ActualDbSchema.config[:migrations_storage] = :db
end
def teardown
ActualDbSchema.config[:migrations_storage] = :file
end
def test_that_it_has_a_version_number
refute_nil ::ActualDbSchema::VERSION
end
end
================================================
FILE: test/test_database_filtering.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "database filtering" do
let(:utils) do
TestUtils.new(
migrations_path: ["db/migrate", "db/migrate_secondary"],
migrated_path: ["tmp/migrated", "tmp/migrated_migrate_secondary"]
)
end
# Helper to extract config name that works with Rails 6.0 (spec_name) and Rails 6.1+ (name)
def config_name(db_config)
if db_config.respond_to?(:name)
db_config.name.to_sym
elsif db_config.respond_to?(:spec_name)
db_config.spec_name.to_sym
else
:primary
end
end
before do
# Reset to default config
ActualDbSchema.config.excluded_databases = []
end
after do
# Clean up configuration after each test
ActualDbSchema.config.excluded_databases = []
end
describe "with excluded_databases configuration" do
it "excludes databases from the excluded_databases list" do
db_config = TestingState.db_config.dup
utils.reset_database_yml(db_config)
ActiveRecord::Base.configurations = { "test" => db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => db_config }
# Configure to exclude secondary database
ActualDbSchema.config.excluded_databases = [:secondary]
# Get the migration context instance
context = ActualDbSchema::MigrationContext.instance
# Verify only primary database is included
configs = context.send(:configs)
config_names = configs.map { |c| config_name(c) }
assert_includes config_names, :primary
refute_includes config_names, :secondary
end
it "allows excluding multiple databases" do
db_config = {
"primary" => TestingState.db_config["primary"],
"secondary" => TestingState.db_config["secondary"],
"queue" => {
"adapter" => "sqlite3",
"database" => "tmp/queue.sqlite3",
"migrations_paths" => Rails.root.join("db", "migrate_queue").to_s
}
}
utils.reset_database_yml(db_config)
ActiveRecord::Base.configurations = { "test" => db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => db_config }
# Configure to exclude secondary and queue databases
ActualDbSchema.config.excluded_databases = %i[secondary queue]
# Get the migration context instance
context = ActualDbSchema::MigrationContext.instance
# Verify only primary database is included
configs = context.send(:configs)
config_names = configs.map { |c| config_name(c) }
assert_includes config_names, :primary
refute_includes config_names, :secondary
refute_includes config_names, :queue
end
it "processes all databases when excluded_databases is empty" do
db_config = TestingState.db_config.dup
utils.reset_database_yml(db_config)
ActiveRecord::Base.configurations = { "test" => db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => db_config }
ActualDbSchema.config.excluded_databases = []
context = ActualDbSchema::MigrationContext.instance
configs = context.send(:configs)
config_names = configs.map { |c| config_name(c) }
assert_includes config_names, :primary
assert_includes config_names, :secondary
end
end
describe "environment variable ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES" do
it "parses comma-separated database names from environment variable" do
ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"] = "queue,cable"
# Create a new configuration to pick up the env var
config = ActualDbSchema::Configuration.new
assert_equal %i[queue cable], config.excluded_databases
ensure
ENV.delete("ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES")
end
it "handles whitespace in environment variable" do
ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"] = "queue, cable, cache"
config = ActualDbSchema::Configuration.new
assert_equal %i[queue cable cache], config.excluded_databases
ensure
ENV.delete("ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES")
end
it "returns empty array when environment variable is not set" do
ENV.delete("ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES")
config = ActualDbSchema::Configuration.new
assert_equal [], config.excluded_databases
end
it "handles empty string in environment variable" do
ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"] = ""
config = ActualDbSchema::Configuration.new
assert_equal [], config.excluded_databases
ensure
ENV.delete("ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES")
end
it "filters out empty values from comma-separated list" do
ENV["ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES"] = "queue,,cable, ,cache"
config = ActualDbSchema::Configuration.new
assert_equal %i[queue cable cache], config.excluded_databases
ensure
ENV.delete("ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES")
end
end
end
================================================
FILE: test/test_helper.rb
================================================
# frozen_string_literal: true
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
# Clear DATABASE_URL to prevent it from overriding the test database configuration
ENV.delete("DATABASE_URL")
require "logger"
require "rails/all"
require "actual_db_schema"
require "minitest/autorun"
require "debug"
require "rake"
require "fileutils"
require "support/test_utils"
Rails.env = "test"
class FakeApplication < Rails::Application
def initialize
super
config.root = File.join(__dir__, "dummy_app")
end
end
Rails.application = FakeApplication.new
class TestingState
class << self
attr_accessor :up, :down, :output
end
def self.reset
self.up = []
self.down = []
ActualDbSchema.failed = []
self.output = +""
end
def self.db_config
adapter = ENV.fetch("DB_ADAPTER", "sqlite3")
case adapter
when "sqlite3"
sqlite3_config
when "postgresql"
postgresql_config
when "mysql2"
mysql2_config
else
raise "Unsupported adapter: #{adapter}"
end
end
def self.sqlite3_config
{
"primary" => {
"adapter" => "sqlite3",
"database" => "tmp/primary.sqlite3",
"migrations_paths" => Rails.root.join("db", "migrate").to_s
},
"secondary" => {
"adapter" => "sqlite3",
"database" => "tmp/secondary.sqlite3",
"migrations_paths" => Rails.root.join("db", "migrate_secondary").to_s
}
}
end
def self.postgresql_config
{
"primary" => {
"adapter" => "postgresql",
"database" => "actual_db_schema_test",
"username" => "postgres",
"password" => "password",
"host" => "localhost",
"port" => 5432,
"migrations_paths" => Rails.root.join("db", "migrate").to_s
},
"secondary" => {
"adapter" => "postgresql",
"database" => "actual_db_schema_test_secondary",
"username" => "postgres",
"password" => "password",
"host" => "localhost",
"port" => 5432,
"migrations_paths" => Rails.root.join("db", "migrate_secondary").to_s
}
}
end
def self.mysql2_config
{
"primary" => {
"adapter" => "mysql2",
"database" => "actual_db_schema_test",
"username" => "root",
"password" => "password",
"host" => "127.0.0.1",
"port" => "3306",
"migrations_paths" => Rails.root.join("db", "migrate").to_s
},
"secondary" => {
"adapter" => "mysql2",
"database" => "actual_db_schema_test_secondary",
"username" => "root",
"password" => "password",
"host" => "127.0.0.1",
"port" => "3306",
"migrations_paths" => Rails.root.join("db", "migrate_secondary").to_s
}
}
end
reset
end
ActualDbSchema.config[:enabled] = true
module Minitest
class Test
def before_setup
super
if defined?(ActualDbSchema)
ActualDbSchema::Store.instance.reset_adapter
ActualDbSchema.failed = []
end
cleanup_migrated_cache if defined?(Rails) && Rails.respond_to?(:root)
clear_db_storage_tables if defined?(TestingState)
ActualDbSchema.config[:migrations_storage] = :file if defined?(ActualDbSchema)
return unless defined?(ActualDbSchema::Migration)
ActualDbSchema::Migration.instance.instance_variable_set(:@metadata, {})
end
private
def cleanup_migrated_cache
Dir.glob(Rails.root.join("tmp", "migrated*")).each { |path| FileUtils.rm_rf(path) }
FileUtils.rm_rf(Rails.root.join("custom", "migrated"))
end
def clear_db_storage_tables
db_storage_configs.each do |config|
ActiveRecord::Base.establish_connection(**config)
drop_db_storage_table(ActiveRecord::Base.connection)
rescue StandardError
next
end
end
def db_storage_configs
db_config = TestingState.db_config
return db_config.values if db_config.is_a?(Hash) && db_config.key?("primary")
[db_config]
end
def drop_db_storage_table(conn)
table_name = "actual_db_schema_migrations"
if conn.adapter_name =~ /postgresql|mysql/i
drop_db_storage_table_in_schemas(conn, table_name)
elsif conn.table_exists?(table_name)
conn.drop_table(table_name)
end
end
def drop_db_storage_table_in_schemas(conn, table_name)
schemas = conn.select_values(<<~SQL.squish)
SELECT table_schema
FROM information_schema.tables
WHERE table_name = #{conn.quote(table_name)}
SQL
schemas.each do |schema|
conn.execute("DROP TABLE IF EXISTS #{conn.quote_table_name(schema)}.#{conn.quote_table_name(table_name)}")
end
end
end
end
module Kernel
alias original_puts puts
def puts(*args)
TestingState.output << args.join("\n")
original_puts(*args)
end
end
================================================
FILE: test/test_migration_context.rb
================================================
# frozen_string_literal: true
require "test_helper"
describe "ActualDbSchema::MigrationContext#each" do
let(:utils) do
TestUtils.new(
migrations_path: ["db/migrate", "db/migrate_secondary"],
migrated_path: ["tmp/migrated", "tmp/migrated_migrate_secondary"]
)
end
before do
utils.reset_database_yml(TestingState.db_config)
ActiveRecord::Base.configurations = { "test" => TestingState.db_config }
ActiveRecord::Tasks::DatabaseTasks.database_configuration = { "test" => TestingState.db_config }
utils.cleanup(TestingState.db_config)
# Establish connection to primary as the "original" connection before iterating
ActiveRecord::Base.establish_connection(**TestingState.db_config["primary"])
end
it "restores the original connection after iterating over multiple databases" do
primary_db = File.basename(TestingState.db_config["primary"]["database"])
# Iterating switches the connection to each database in turn (primary, then secondary)
ActualDbSchema::MigrationContext.instance.each { |_context| }
# After iteration, the connection must be restored to the original (primary) database.
# Without restoration, the connection is left on the last database (secondary), which
# means any subsequent ActiveRecord queries silently hit the wrong database.
current_db = File.basename(current_database)
assert_equal primary_db, current_db,
"MigrationContext#each must restore the original connection after iteration, " \
"but was left on '#{current_db}' instead of '#{primary_db}'"
end
private
def current_database
if ActiveRecord::Base.respond_to?(:connection_db_config)
ActiveRecord::Base.connection_db_config.database
else
ActiveRecord::Base.connection_config[:database]
end
end
end