Repository: widefix/actual_db_schema Branch: main Commit: 0ea98c4afbf3 Files: 101 Total size: 324.0 KB Directory structure: gitextract_9537yhwc/ ├── .github/ │ └── workflows/ │ └── main.yml ├── .gitignore ├── .rubocop.yml ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── actual_db_schema.gemspec ├── app/ │ ├── controllers/ │ │ └── actual_db_schema/ │ │ ├── broken_versions_controller.rb │ │ ├── migrations_controller.rb │ │ ├── phantom_migrations_controller.rb │ │ └── schema_controller.rb │ └── views/ │ └── actual_db_schema/ │ ├── broken_versions/ │ │ └── index.html.erb │ ├── migrations/ │ │ ├── index.html.erb │ │ └── show.html.erb │ ├── phantom_migrations/ │ │ ├── index.html.erb │ │ └── show.html.erb │ ├── schema/ │ │ └── index.html.erb │ └── shared/ │ ├── _js.html │ └── _style.html ├── bin/ │ ├── console │ └── setup ├── config/ │ └── routes.rb ├── docker/ │ ├── mysql-init/ │ │ └── create_secondary_db.sql │ └── postgres-init/ │ └── create_secondary_db.sql ├── docker-compose.yml ├── gemfiles/ │ ├── rails.6.0.gemfile │ ├── rails.6.1.gemfile │ ├── rails.7.0.gemfile │ ├── rails.7.1.gemfile │ └── rails.edge.gemfile ├── lib/ │ ├── actual_db_schema/ │ │ ├── commands/ │ │ │ ├── base.rb │ │ │ ├── list.rb │ │ │ └── rollback.rb │ │ ├── configuration.rb │ │ ├── console_migrations.rb │ │ ├── engine.rb │ │ ├── failed_migration.rb │ │ ├── git.rb │ │ ├── git_hooks.rb │ │ ├── instrumentation.rb │ │ ├── migration.rb │ │ ├── migration_context.rb │ │ ├── migration_parser.rb │ │ ├── multi_tenant.rb │ │ ├── output_formatter.rb │ │ ├── patches/ │ │ │ ├── migration_context.rb │ │ │ ├── migration_proxy.rb │ │ │ └── migrator.rb │ │ ├── railtie.rb │ │ ├── rollback_stats_repository.rb │ │ ├── schema_diff.rb │ │ ├── schema_diff_html.rb │ │ ├── schema_parser.rb │ │ ├── store.rb │ │ ├── structure_sql_parser.rb │ │ └── version.rb │ ├── actual_db_schema.rb │ ├── generators/ │ │ └── actual_db_schema/ │ │ └── templates/ │ │ └── actual_db_schema.rb │ └── tasks/ │ ├── actual_db_schema.rake │ ├── db.rake │ └── test.rake ├── sig/ │ └── actual_db_schema.rbs └── test/ ├── controllers/ │ └── actual_db_schema/ │ ├── broken_versions_controller_db_storage_test.rb │ ├── broken_versions_controller_test.rb │ ├── migrations_controller_db_storage_test.rb │ ├── migrations_controller_test.rb │ ├── phantom_migrations_controller_db_storage_test.rb │ ├── phantom_migrations_controller_test.rb │ ├── schema_controller_db_storage_test.rb │ └── schema_controller_test.rb ├── dummy_app/ │ ├── config/ │ │ └── .keep │ ├── db/ │ │ ├── migrate/ │ │ │ └── .keep │ │ └── migrate_secondary/ │ │ └── .keep │ └── public/ │ └── 404.html ├── rake_task_console_migrations_db_storage_test.rb ├── rake_task_console_migrations_test.rb ├── rake_task_db_storage_full_test.rb ├── rake_task_db_storage_test.rb ├── rake_task_delete_broken_versions_db_storage_test.rb ├── rake_task_delete_broken_versions_test.rb ├── rake_task_git_hooks_install_db_storage_test.rb ├── rake_task_git_hooks_install_test.rb ├── rake_task_multi_tenant_db_storage_test.rb ├── rake_task_multi_tenant_test.rb ├── rake_task_schema_diff_db_storage_test.rb ├── rake_task_schema_diff_test.rb ├── rake_task_secondary_db_storage_test.rb ├── rake_task_secondary_test.rb ├── rake_task_test.rb ├── rake_tasks_all_databases_db_storage_test.rb ├── rake_tasks_all_databases_test.rb ├── support/ │ └── test_utils.rb ├── test_actual_db_schema.rb ├── test_actual_db_schema_db_storage_test.rb ├── test_database_filtering.rb ├── test_helper.rb └── test_migration_context.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/main.yml ================================================ name: Ruby on: push: branches: - main pull_request: jobs: build: runs-on: ubuntu-latest name: Ruby ${{ matrix.ruby }}, Rails ${{ matrix.rails }} strategy: matrix: include: - { ruby: '2.7', rails: '6.0' } - { ruby: '2.7', rails: '6.1' } - { ruby: '3.0', rails: '6.1' } - { ruby: '3.1', rails: '7.0' } - { ruby: '3.2', rails: '7.1' } - { ruby: '3.3', rails: '7.1' } - { ruby: '3.3', rails: 'edge' } env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails.${{ matrix.rails }}.gemfile steps: - uses: actions/checkout@v2 - name: Install SQLite3 Development Libraries run: sudo apt-get update && sudo apt-get install -y libsqlite3-dev docker-compose - name: Set up Ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - name: Run Tests with All Adapters run: bundle exec rake test:all rubocop: runs-on: ubuntu-latest name: Rubocop steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: 2.7 bundler-cache: true - run: bundle exec rubocop ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ /test/dummy_app/tmp/ /test/dummy_app/custom/ /test/dummy_app/db/**/*.rb /test/dummy_app/db/structure.sql /test/dummy_app/config/database.yml .ruby-version .ruby-gemset /gemfiles/*.gemfile.lock ================================================ FILE: .rubocop.yml ================================================ AllCops: TargetRubyVersion: 2.7 Exclude: - gemfiles/* - test/dummy_app/**/* - vendor/**/* Style/StringLiterals: Enabled: true EnforcedStyle: double_quotes Style/StringLiteralsInInterpolation: Enabled: true EnforcedStyle: double_quotes Layout/LineLength: Max: 120 Metrics/BlockLength: Exclude: - test/**/* - actual_db_schema.gemspec Metrics/MethodLength: Max: 15 Exclude: - test/**/* Metrics/ClassLength: Enabled: false Metrics/ModuleLength: Enabled: false Metrics/AbcSize: Max: 25 Metrics/CyclomaticComplexity: Max: 10 Metrics/PerceivedComplexity: Max: 10 Metrics/AbcSize: Enabled: false ================================================ FILE: Appraisals ================================================ # frozen_string_literal: true %w[6.0 6.1 7.0 7.1].each do |version| appraise "rails.#{version}" do gem "activerecord", "~> #{version}.0" gem "activesupport", "~> #{version}.0" end end appraise "rails.edge" do gem "rails", ">= 7.2.0.beta" gem "activerecord", ">= 7.2.0.beta" gem "activesupport", ">= 7.2.0.beta" end ================================================ FILE: CHANGELOG.md ================================================ ## [0.9.1] - 2026-02-25 - Support schema diffs for `structure.sql` - Add an option to exclude specific databases from the gem's visibility scope - Fix a crash when the database is not available at application startup - Add instrumentation tooling to track stats about rolled-back phantom migrations ## [0.9.0] - 2026-01-27 - Store migration files in the DB to avoid reliance on the filesystem, enabling CI/CD usage on platforms with ephemeral storage (e.g., Heroku, Docker). ## [0.8.6] - 2025-05-21 - Fix gem installtion with git hooks - Update README ## [0.8.5] - 2025-04-10 - Fix the gem working on projects without git ## [0.8.4] - 2025-03-20 - Fix initializer file that can break other bundle groups that development - Use prism gem instead of parser for Ruby 3.4 compatibility ## [0.8.3] - 2025-03-03 - View Schema with Migration Annotations in the UI - Clean Up Broken Migrations - Filter Migrations in the UI - Customize Your Migrated Folder Location ## [0.8.2] - 2025-02-06 - Show migration name in the schema.rb diff that caused the change - Easy way to run DDL migration methods in Rails console ## [0.8.1] - 2025-01-15 - Support for multiple database schemas, ensuring compatibility with multi-tenant applications using the apartment gem or similar solutions - DSL for configuring the gem, simplifying setup and customization - Rake task added to initialize the gem - Improved the post-checkout git hook to run only when switching branches, reducing unnecessary executions during file checkouts - Fixed the changelog link in the gemspec, ensuring Rubygems points to the correct file and the link works ## [0.8.0] - 2024-12-30 - Enhanced Console Visibility: Automatically rolled-back phantom migrations now provide clearer and more visible logs in the console - Git Hooks for Branch Management: Introduced hooks that automatically rollback phantom migrations after checking out a branch. Additionally, the schema migration rake task can now be executed automatically upon branch checkout - Temporary Folder Cleanup: Rolled-back phantom migrations are now automatically deleted from the temporary folder after rollback - Acronym Support in Phantom Migration Names: Resolved an issue where phantom migrations with acronyms in their names, defined in other branches, couldn't be rolled back automatically. These are now handled seamlessly ## [0.7.9] - 2024-09-07 - Don't stop if a phantom migration rollback fails - Improve failed rollback of phantom migrations report ## [0.7.8] - 2024-08-07 - Make UI working without assets pipeline ## [0.7.7] - 2024-07-22 - Unlock compatibility with Rails versions earlier than 6.0 ## [0.7.6] - 2024-07-22 - Added UI - Added environment variable `ACTUAL_DB_SCHEMA_UI_ENABLED` to enable/disable the UI in specific environments - Added configuration option `ActualDbSchema.config[:ui_enabled]` to enable/disable the UI in specific environments ## [0.7.5] - 2024-06-20 - Added db:rollback_migrations:manual task to manually rolls back phantom migrations one by one ## [0.7.4] - 2024-06-06 - Rails 7.2 support added - Rails 6.0 support added ## [0.7.3] - 2024-04-06 - add multipe databases support ## [0.7.2] - 2024-03-30 - update title and description in Rubygems ## [0.7.1] - 2024-03-19 - add csv as a dependency since Ruby 3.3 has removed it from the standard library ## [0.7.0] - 2024-01-18 - db:phantom_migrations displays the branch in which the phantion migration was run ## [0.6.0] - 2024-01-03 - Added db:phantom_migrations task to display phantom migrations - Updated README ## [0.5.0] - 2023-11-06 - Rails 7.1 support added ## [0.4.0] - 2023-07-05 - rollback migrations in the reversed order ## [0.3.0] - 2023-01-23 - add Rails 6 and older support ## [0.2.0] - 2022-10-19 - Catch exceptions about irreversible migrations and show a warning - Namespace all patches into gem module - Fix typo in a module name with a patch - Use guard clause ## [0.1.0] - 2022-10-16 - Initial release ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at ka8725@gmail.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true source "https://rubygems.org" # Specify your gem's dependencies in actual_db_schema.gemspec gemspec gem "activerecord", "~> 7.1.0" gem "activesupport", "~> 7.1.0" gem "minitest", "~> 5.0" gem "rake" gem "rubocop", "~> 1.21" ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2022 Andrei Kaleshka Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![Gem Version](https://badge.fury.io/rb/actual_db_schema.svg)](https://badge.fury.io/rb/actual_db_schema) # ActualDbSchema **Stop database headaches when switching Git branches in Rails** Keep your database schema perfectly synchronized across Git branches, eliminate broken tests and schema conflicts, and save wasted hours on phantom migrations. ## 🚀 What You Get - **Zero Manual Work**: Switch branches freely - phantom migrations roll back automatically - **No More Schema Conflicts**: Clean `schema.rb`/`structure.sql` diffs every time, no irrelevant changes - **Error Prevention**: Eliminates `ActiveRecord::NotNullViolation` and similar errors when switching branches - **Time Savings**: Stop hunting down which branch has the problematic migration - **Team Productivity**: Everyone stays focused on coding, not database maintenance - **Staging/Sandbox Sync**: Keep staging and sandbox databases aligned with your current branch code - **Visual Management**: Web UI to view and manage migrations across all databases Visual management of Rails DB migrations with ActualDbSchema And you get all of that with **zero** changes to your workflow! ## 🎯 The Problem This Solves **Before ActualDbSchema:** 1. Work on Branch A → Add migration → Run migration 2. Switch to Branch B → Code breaks with database errors 3. Manually find and rollback the "phantom" migration 4. Deal with irrelevant `schema.rb` diffs 5. Repeat this tedious process constantly **After ActualDbSchema:** 1. Work on any branch → Add migrations as usual 2. Switch branches freely → Everything just works 3. Focus on building features, not fixing database issues ## 🌟 Complete Feature Set ### Core Migration Management - **Phantom Migration Detection**: Automatically identifies migrations from other branches - **Smart Rollback**: Rolls back phantom migrations in correct dependency order - **Irreversible Migration Handling**: Safely handles and reports irreversible migrations - **Multi-Database Support**: Works seamlessly with multiple database configurations - **Schema Format Agnostic**: Supports both `schema.rb` and `structure.sql` ### Automation & Git Integration - **Automatic Rollback on Migration**: Phantom migrations roll back when running `db:migrate` - **Git Hook Integration**: Optional automatic rollback when switching branches - **Zero Configuration**: Works out of the box with sensible defaults - **Custom Migration Storage**: Configurable location for storing executed migrations ### Web Interface & Management - **Migration Dashboard**: Visual overview of all migrations across databases - **Phantom Migration Browser**: Easy-to-use interface for viewing phantom migrations - **One-Click Rollback**: Rollback individual or all phantom migrations via web UI - **Broken Version Cleanup**: Identify and remove orphaned migration records - **Schema Diff Viewer**: Visual diff of schema changes with migration annotations ### Developer Tools - **Console Migrations**: Run migration commands directly in Rails console - **Schema Diff Analysis**: Annotated diffs showing which migrations caused changes - **Migration Search & Filter**: Find specific migrations across all databases - **Detailed Migration Info**: View migration status, branch, and database information ### Team & Environment Support - **Multi-Tenant Compatible**: Works with apartment gem and similar multi-tenant setups - **Environment Flexibility**: Enable/disable features per environment - **Team Synchronization**: Keeps all team members' databases in sync - **CI/CD Friendly**: No interference with deployment pipelines ### Manual Control Options - **Manual Rollback Mode**: Disable automatic rollback for full manual control - **Selective Rollback**: Choose which phantom migrations to rollback - **Interactive Mode**: Step-by-step confirmation for each rollback operation - **Rake Task Integration**: Full set of rake tasks for command-line management ## ⚡ Quick Start Add to your Gemfile: ```ruby group :development do gem "actual_db_schema" end ``` Install and configure: ```sh bundle install rails actual_db_schema:install ``` That's it! Now just run `rails db:migrate` as usual - phantom migrations roll back automatically. ## 🔧 How It Works This gem stores all run migrations with their code in the `tmp/migrated` folder. Whenever you perform a schema dump, it rolls back the *phantom migrations*. The *phantom migrations* list is the difference between the migrations you've executed (in the `tmp/migrated` folder) and the current ones (in the `db/migrate` folder). Therefore, all you do is run rails `db:migrate` in your current branch. `actual_db_schema` will ensure the DB schema is up-to-date. You'll never have an inaccurate `schema.rb` file again. ## Installation Add this line to your application's Gemfile: ```ruby group :development do gem "actual_db_schema" end ``` And then execute: $ bundle install If you cannot commit changes to the repo or Gemfile, consider the local Gemfile installation described in [this post](https://blog.widefix.com/personal-gemfile-for-development/). Next, generate your ActualDbSchema initializer file by running: ```sh rails actual_db_schema:install ``` This will create a `config/initializers/actual_db_schema.rb` file that lists all available configuration options, allowing you to customize them as needed. The installation process will also prompt you to install the post-checkout Git hook, which automatically rolls back phantom migrations when switching branches. If enabled, this hook will run the schema actualization rake task every time you switch branches, which can slow down branch changes. Therefore, you might not always want this automatic actualization on every switch; in that case, running `rails db:migrate` manually provides a faster, more controlled alternative. For more details on the available configuration options, see the sections below. ## Usage Just run `rails db:migrate` inside the current branch. It will roll back all phantom migrations for all configured databases in your `database.yml.` > [!WARNING] > This solution implies that all migrations are reversible. The irreversible migrations should be solved manually. At the moment, the gem ignores them. You will see warnings in the terminal for each irreversible migrations. The gem offers the following rake tasks that can be manually run according to your preferences: - `rails db:rollback_branches` - run it to manually rolls back phantom migrations. - `rails db:rollback_branches:manual` - run it to manually rolls back phantom migrations one by one. - `rails db:phantom_migrations` - displays a list of phantom migrations. ## 🎛️ Configuration Options By default, `actual_db_schema` stores all run migrations in the `tmp/migrated` folder. However, if you want to change this location, you can configure it in two ways: ### 1. Using Environment Variable Set the environment variable `ACTUAL_DB_SCHEMA_MIGRATED_FOLDER` to your desired folder path: ```sh export ACTUAL_DB_SCHEMA_MIGRATED_FOLDER="custom/migrated" ``` ### 2. Using Initializer Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`): ```ruby config.migrated_folder = Rails.root.join("custom", "migrated") ``` ### 3. Store migrations in the database If you want to share executed migrations across environments (e.g., staging or sandboxes), store them in the main database instead of the local filesystem: ```ruby config.migrations_storage = :db ``` Or via environment variable: ```sh export ACTUAL_DB_SCHEMA_MIGRATIONS_STORAGE="db" ``` If both are set, the initializer setting (`config.migrations_storage`) takes precedence. ## 🌐 Web Interface Access the migration management UI at: ``` http://localhost:3000/rails/phantom_migrations ``` View and manage: - **Migration Overview**: See all executed migrations with their status, branch, and database - **Phantom Migrations**: Identify migrations from other branches that need rollback - **Migration Source Code**: Browse the source code of every migration ever run (including the phantom ones) - **One-Click Actions**: Rollback or migrate individual migrations directly from the UI - **Broken Versions**: Detect and clean up orphaned migration records safely - **Schema Diffs**: Visual diff of schema changes annotated with their source migrations ## UI options By default, the UI is enabled in the development environment. If you prefer to enable the UI for another environment, you can do so in two ways: ### 1. Using Environment Variable Set the environment variable `ACTUAL_DB_SCHEMA_UI_ENABLED` to `true`: ```sh export ACTUAL_DB_SCHEMA_UI_ENABLED=true ``` ### 2. Using Initializer Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`): ```ruby config.ui_enabled = true ``` > With this option, the UI can be disabled for all environments or be enabled in specific ones. ## Disabling Automatic Rollback By default, the automatic rollback of migrations is enabled. If you prefer to perform manual rollbacks, you can disable the automatic rollback in two ways: ### 1. Using Environment Variable Set the environment variable `ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED` to `true`: ```sh export ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED=true ``` ### 2. Using Initializer Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`): ```ruby config.auto_rollback_disabled = true ``` ## Rollback Instrumentation ActualDbSchema emits an `ActiveSupport::Notifications` event for each successful phantom rollback: - Event name: `rollback_migration.actual_db_schema` - Event is always emitted when a phantom rollback succeeds ### Event payload | Field | Description | |-------|-------------| | `version` | Migration version that was rolled back | | `name` | Migration class name | | `database` | Current database name from Active Record config | | `schema` | Tenant schema name (or `nil` for default schema) | | `branch` | Branch associated with the migration metadata | | `manual_mode` | Whether rollback was run in manual mode | ### Subscribing to rollback events You can subscribe to rollback events in your initializer to track statistics or perform custom actions: ```ruby # config/initializers/actual_db_schema.rb ActiveSupport::Notifications.subscribe(ActualDbSchema::Instrumentation::ROLLBACK_EVENT) do |_name, _start, _finish, _id, payload| ActualDbSchema::RollbackStatsRepository.record(payload) end ``` The `RollbackStatsRepository` persists rollback events to a database table (`actual_db_schema_rollback_events`) that is automatically excluded from schema dumps. Read aggregated stats at runtime: ```ruby ActualDbSchema::RollbackStatsRepository.stats # => { total: 3, by_database: { "primary" => 3 }, by_schema: { "default" => 3 }, by_branch: { "main" => 3 } } ActualDbSchema::RollbackStatsRepository.total_rollbacks # => 3 ActualDbSchema::RollbackStatsRepository.reset! # Clears all recorded stats ``` ## Automatic Phantom Migration Rollback On Branch Switch By default, the automatic rollback of migrations on branch switch is disabled. If you prefer to automatically rollback phantom migrations whenever you switch branches with `git checkout`, you can enable it in two ways: ### 1. Using Environment Variable Set the environment variable `ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED` to `true`: ```sh export ACTUAL_DB_SCHEMA_GIT_HOOKS_ENABLED=true ``` ### 2. Using Initializer Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`): ```ruby config.git_hooks_enabled = true ``` ### Installing the Post-Checkout Hook After enabling Git hooks in your configuration, run the rake task to install the post-checkout hook: ```sh rake actual_db_schema:install_git_hooks ``` This task will prompt you to choose one of the three options: 1. Rollback phantom migrations with `db:rollback_branches` 2. Migrate up to the latest schema with `db:migrate` 3. Skip installing git hook Based on your selection, a post-checkout hook will be installed or updated in your `.git/hooks` folder. ## Excluding Databases from Processing **For Rails 6.1+ applications using multiple databases** (especially with infrastructure databases like Solid Queue, Solid Cable, or Solid Cache), you can exclude specific databases from ActualDbSchema's processing to prevent connection conflicts. ### Why You Might Need This Modern Rails applications often use the `connects_to` pattern for infrastructure databases. These databases maintain their own isolated connection pools, and ActualDbSchema's global connection switching can interfere with active queries. This is particularly common with: - **Solid Queue** (Rails 8 default job backend) - **Solid Cable** (WebSocket connections) - **Solid Cache** (caching infrastructure) ### Method 1: Using `excluded_databases` Configuration Explicitly exclude databases by name in your initializer: ```ruby # config/initializers/actual_db_schema.rb ActualDbSchema.configure do |config| config.excluded_databases = [:queue, :cable, :cache] end ``` ### Method 2: Using Environment Variable Set the environment variable `ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES` with a comma-separated list: ```sh export ACTUAL_DB_SCHEMA_EXCLUDED_DATABASES="queue,cable,cache" ``` **Note:** If both the environment variable and the configuration setting in the initializer are provided, the configuration setting takes precedence as it's applied after the default settings are loaded. ## Multi-Tenancy Support If your application leverages multiple schemas for multi-tenancy — such as those implemented by the [apartment](https://github.com/influitive/apartment) gem or similar solutions — you can configure ActualDbSchema to handle migrations across all schemas. To do so, add the following configuration to your initializer file (`config/initializers/actual_db_schema.rb`): ```ruby config.multi_tenant_schemas = -> { # list of all active schemas } ``` ### Example: ```ruby config.multi_tenant_schemas = -> { ["public", "tenant1", "tenant2"] } ``` ## Schema Diff with Migration Annotations If `schema.rb` generates a diff, it can be helpful to find out which migrations caused the changes. This helps you decide whether to resolve the diff on your own or discuss it with your teammates to determine the next steps. The `diff_schema_with_migrations` Rake task generates a diff of the `schema.rb` file, annotated with the migrations responsible for each change. This makes it easier to trace which migration introduced a specific schema modification, enabling faster and more informed decision-making regarding how to handle the diff. By default, the task uses `db/schema.rb` and `db/migrate` as the schema and migrations paths. You can also provide custom paths as arguments. Alternatively, if you use Web UI, you can see this diff at `http://localhost:3000/rails/schema`. This way is often more convenient than running the Rake task manually. ### Usage Run the task with default paths: ```sh rake actual_db_schema:diff_schema_with_migrations ``` Run the task with custom paths: ```sh rake actual_db_schema:diff_schema_with_migrations[path/to/custom_schema.rb, path/to/custom_migrations] ``` ## Console Migrations Sometimes, it's necessary to modify the database without creating migration files. This can be useful for fixing a corrupted schema, conducting experiments (such as adding and removing indexes), or quickly adjusting the schema in development. This gem allows you to run the same commands used in migrations directly in the Rails console. By default, Console Migrations is disabled. You can enable it in two ways: ### 1. Using Environment Variable Set the environment variable `ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED` to `true`: ```sh export ACTUAL_DB_SCHEMA_CONSOLE_MIGRATIONS_ENABLED=true ``` ### 2. Using Initializer Add the following line to your initializer file (`config/initializers/actual_db_schema.rb`): ```ruby config.console_migrations_enabled = true ``` ### Usage Once enabled, you can run migration commands directly in the Rails console: ```ruby # Create a new table create_table :posts do |t| t.string :title end # Add a column add_column :users, :age, :integer # Remove an index remove_index :users, :email # Rename a column rename_column :users, :username, :handle ``` ## Delete Broken Migrations A migration is considered broken if it has been migrated in the database but the corresponding migration file is missing. This functionality allows you to safely delete these broken versions from the database to keep it clean. You can delete broken migrations using either of the following methods: ### 1. Using the UI Navigate to the following URL in your web browser: ``` http://localhost:3000/rails/broken_versions ``` This page lists all broken versions and provides an option to delete them. ### 2. Using a Rake Task To delete all broken migrations, run: ```sh rake actual_db_schema:delete_broken_versions ``` To delete specific migrations, pass the migration version(s) and optionally a database: ```sh rake actual_db_schema:delete_broken_versions[, ] ``` - `` – The migration version(s) to delete (space-separated if multiple). - `` (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? %> <% broken_versions.each do |version| %> <% end %>
Status Migration ID Branch Database Actions
<%= 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' %>
<% 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" %>
<% if migrations.present? %> <% migrations.each do |migration| %> <% end %>
Status Migration ID Name Branch Database Actions
<%= 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]) %>
<% 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? %> <% phantom_migrations.each do |migration| %> <% end %>
Status Migration ID Name Branch Database Actions
<%= 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' %>
<% 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" %>
<%= 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+ :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