From 528a92969f491c85c1a8c29a4665ab922ea271c0 Mon Sep 17 00:00:00 2001 From: Andrei Kaleshka Date: Fri, 19 Jul 2024 14:25:59 +0200 Subject: [PATCH] Implement UI (#83) * Add UI * fixup! Add UI * fixup! fixup! Add UI * Add tests draft * Add tests * fixup! Add tests * fixup! fixup! Add tests * Refactoring * fixup! Refactoring * fixup! fixup! Refactoring * fixup! fixup! fixup! Refactoring * fixup! fixup! fixup! fixup! Refactoring * fixup! fixup! fixup! fixup! fixup! Refactoring * fixup! fixup! fixup! fixup! fixup! fixup! Refactoring * Add Migration class * fixup! Add Migration class * fixup! fixup! Add Migration class * Add DatabaseConnection class * Add unecode icons to buttons * fixup! Add DatabaseConnection class * fixup! fixup! Add DatabaseConnection class * fixup! fixup! fixup! fixup! fixup! fixup! fixup! Refactoring * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Refactoring * fixup! fixup! fixup! Add tests * Add new env var for "disabled" * Remove railtie * fixup! fixup! fixup! Add DatabaseConnection class * fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Refactoring * Memoize helper methods * Rename migrations to phantom migrations * Fix render not_found and add test * Revert changes for metadata method * add migration not found text * hide "down" migrations * fix tests * fixup! fix tests * Reduce Test Execution Time * Add migrations controller * fixup! Add migrations controller * fixup! fixup! Add migrations controller * fixup! fixup! fixup! Add migrations controller * Add test * Add buttons * Add migrate button * Fix tests * fixup! fixup! fixup! fixup! Add migrations controller * Fix Test * show only the relevant buttons * remove debugger * Truncate name field * Change the sorting to DESC * fix buttons style * hide migrations with down status * revert changes * fix tests * fixup! fix tests * Update .gitignore to ignore all .rb files in db and its subdirectories * fix migrations list * hide migrations with down status * Add Rollback All button * Add test * fix warning * fix rollback_all * fix * fixup! fix * fix background colors for rows --------- Co-authored-by: Vladislav Sokov --- .gitignore | 3 +- CHANGELOG.md | 5 + README.md | 29 ++++ .../stylesheets/actual_db_schema/styles.css | 92 +++++++++++ .../actual_db_schema/migrations_controller.rb | 32 ++++ .../phantom_migrations_controller.rb | 32 ++++ .../migrations/index.html.erb | 66 ++++++++ .../actual_db_schema/migrations/show.html.erb | 54 +++++++ .../phantom_migrations/index.html.erb | 55 +++++++ .../phantom_migrations/show.html.erb | 45 ++++++ config/routes.rb | 18 +++ lib/actual_db_schema.rb | 25 +-- lib/actual_db_schema/commands/base.rb | 22 +-- lib/actual_db_schema/commands/list.rb | 21 +-- lib/actual_db_schema/commands/rollback.rb | 4 +- lib/actual_db_schema/engine.rb | 18 +++ lib/actual_db_schema/migration.rb | 119 ++++++++++++++ lib/actual_db_schema/migration_context.rb | 37 +++++ .../patches/migration_context.rb | 30 ++-- lib/railtie.rb | 15 -- lib/tasks/db.rake | 12 +- .../migrations_controller_test.rb | 118 ++++++++++++++ .../phantom_migrations_controller_test.rb | 145 ++++++++++++++++++ test/dummy_app/config/database.yml | 11 -- test/dummy_app/db/secondary_schema.rb | 14 -- test/dummy_app/public/404.html | 0 test/rake_task_secondary_test.rb | 1 + test/rake_task_test.rb | 1 + test/rake_tasks_all_databases_test.rb | 1 + test/support/test_utils.rb | 32 ++-- test/test_helper.rb | 1 + 31 files changed, 941 insertions(+), 117 deletions(-) create mode 100644 app/assets/stylesheets/actual_db_schema/styles.css create mode 100644 app/controllers/actual_db_schema/migrations_controller.rb create mode 100644 app/controllers/actual_db_schema/phantom_migrations_controller.rb create mode 100644 app/views/actual_db_schema/migrations/index.html.erb create mode 100644 app/views/actual_db_schema/migrations/show.html.erb create mode 100644 app/views/actual_db_schema/phantom_migrations/index.html.erb create mode 100644 app/views/actual_db_schema/phantom_migrations/show.html.erb create mode 100644 config/routes.rb create mode 100644 lib/actual_db_schema/engine.rb create mode 100644 lib/actual_db_schema/migration.rb create mode 100644 lib/actual_db_schema/migration_context.rb delete mode 100644 lib/railtie.rb create mode 100644 test/controllers/actual_db_schema/migrations_controller_test.rb create mode 100644 test/controllers/actual_db_schema/phantom_migrations_controller_test.rb delete mode 100644 test/dummy_app/db/secondary_schema.rb create mode 100644 test/dummy_app/public/404.html diff --git a/.gitignore b/.gitignore index ce1295a..a5aed50 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ /spec/reports/ /tmp/ /test/dummy_app/tmp/ -/test/dummy_app/db/*/*.rb -/test/dummy_app/db/*.rb +/test/dummy_app/db/**/*.rb .ruby-version .ruby-gemset /gemfiles/*.gemfile.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index bd47114..f760bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## [0.7.6] - 2024-07-05 +- 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 diff --git a/README.md b/README.md index 052c9fc..f1e46ac 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,35 @@ The gem offers the following rake tasks that can be manually run according to yo - `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. +## Accessing the UI + +The UI for managing migrations is enabled automatically. To access the UI, simply navigate to the following URL in your web browser: +``` +http://localhost:3000/rails/phantom_migrations +``` +This page displays a list of phantom migrations for each database connection and provides options to view details and rollback them. + +## 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 +ActualDbSchema.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: diff --git a/app/assets/stylesheets/actual_db_schema/styles.css b/app/assets/stylesheets/actual_db_schema/styles.css new file mode 100644 index 0000000..e217533 --- /dev/null +++ b/app/assets/stylesheets/actual_db_schema/styles.css @@ -0,0 +1,92 @@ +body { + margin: 8px; + background-color: #fff; + color: #333; +} + +body, p, td { + font-family: helvetica, verdana, arial, sans-serif; + font-size: 13px; + line-height: 18px; +} + +h2 { + padding-left: 10px; +} + +table { + margin: 0; + border-collapse: collapse; + + thead tr { + border-bottom: 2px solid #ddd; + } + + tbody { + .migration-row.phantom { + background-color: #fff3f3; + } + + .migration-row.normal { + background-color: #ffffff; + } + + .migration-row:nth-child(odd).phantom { + background-color: #ffe6e6; + } + + .migration-row:nth-child(odd).normal { + background-color: #f9f9f9; + } + } + + td { + padding: 14px 30px; + } +} + +.top-buttons { + margin: 8px; + display: flex; + align-items: center; + + .top-button { + background-color: #ddd; + } +} + +.button, .top-button { + font-weight: bold; + color: #000; + border: none; + padding: 5px 10px; + text-align: center; + text-decoration: none; + display: inline-block; + margin: 0 2px; + cursor: pointer; + border-radius: 4px; + transition: background-color 0.3s; + background: none; +} + +.button:hover, .top-button:hover { + color: #fff; + background-color: #000; +} + +.button-container { + display: flex; +} + +pre { + background-color: #f7f7f7; + padding: 10px; + border: 1px solid #ddd; +} + +.truncate-text { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/controllers/actual_db_schema/migrations_controller.rb b/app/controllers/actual_db_schema/migrations_controller.rb new file mode 100644 index 0000000..979bcc9 --- /dev/null +++ b/app/controllers/actual_db_schema/migrations_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActualDbSchema + # Controller to display the list of migrations for each database connection. + class MigrationsController < ActionController::Base + def index; end + + def show + render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found unless migration + end + + def rollback + ActualDbSchema::Migration.instance.rollback(params[:id], params[:database]) + redirect_to migrations_path + end + + def migrate + ActualDbSchema::Migration.instance.migrate(params[:id], params[:database]) + redirect_to migrations_path + end + + private + + helper_method def migrations + @migrations ||= ActualDbSchema::Migration.instance.all + end + + helper_method def migration + @migration ||= ActualDbSchema::Migration.instance.find(params[:id], params[:database]) + end + end +end diff --git a/app/controllers/actual_db_schema/phantom_migrations_controller.rb b/app/controllers/actual_db_schema/phantom_migrations_controller.rb new file mode 100644 index 0000000..8356e8a --- /dev/null +++ b/app/controllers/actual_db_schema/phantom_migrations_controller.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module ActualDbSchema + # Controller to display the list of phantom migrations for each database connection. + class PhantomMigrationsController < ActionController::Base + def index; end + + def show + render file: "#{Rails.root}/public/404.html", layout: false, status: :not_found unless phantom_migration + end + + def rollback + ActualDbSchema::Migration.instance.rollback(params[:id], params[:database]) + redirect_to phantom_migrations_path + end + + def rollback_all + ActualDbSchema::Migration.instance.rollback_all + redirect_to phantom_migrations_path + end + + private + + 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 diff --git a/app/views/actual_db_schema/migrations/index.html.erb b/app/views/actual_db_schema/migrations/index.html.erb new file mode 100644 index 0000000..896f0a5 --- /dev/null +++ b/app/views/actual_db_schema/migrations/index.html.erb @@ -0,0 +1,66 @@ + + + + Migrations + <%= stylesheet_link_tag 'actual_db_schema/styles', media: 'all' %> + + +
+

Migrations

+

+ Red rows represent phantom migrations. +

+
+ <%= link_to 'Phantom Migrations', phantom_migrations_path, class: "top-button" %> +
+ <% if migrations.present? %> + + + + + + + + + + + + + <% migrations.each do |migration| %> + + + + + + + + + <% end %> + +
StatusMigration IDNameBranchDatabaseActions
<%= 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', + style: ('display: none;' if migration[:status] == "down") %> + <%= button_to '⬆ Migrate', + migrate_migration_path(id: migration[:version], database: migration[:database]), + method: :post, + class: 'button', + style: ('display: none;' if migration[:status] == "up" || migration[:phantom]) %> +
+
+ <% else %> +

No migrations found.

+ <% end %> +
+ + diff --git a/app/views/actual_db_schema/migrations/show.html.erb b/app/views/actual_db_schema/migrations/show.html.erb new file mode 100644 index 0000000..e8d122f --- /dev/null +++ b/app/views/actual_db_schema/migrations/show.html.erb @@ -0,0 +1,54 @@ + + + + Migration Details + <%= stylesheet_link_tag 'actual_db_schema/styles', media: 'all' %> + + +
+

Migration <%= migration[:name] %> Details

+ + + + + + + + + + + + + + + + + + + + + + + +
Status<%= migration[:status] %>
Migration ID<%= migration[:version] %>
Branch<%= migration[:branch] %>
Database<%= migration[:database] %>
Path<%= migration[:filename] %>
+ +

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', + style: ('display: none;' if migration[:status] == "down") %> + <%= button_to '⬆ Migrate', + migrate_migration_path(id: migration[:version], database: migration[:database]), + method: :post, + class: 'button', + style: ('display: none;' if migration[:status] == "up" || migration[:phantom]) %> +
+
+ + diff --git a/app/views/actual_db_schema/phantom_migrations/index.html.erb b/app/views/actual_db_schema/phantom_migrations/index.html.erb new file mode 100644 index 0000000..5707276 --- /dev/null +++ b/app/views/actual_db_schema/phantom_migrations/index.html.erb @@ -0,0 +1,55 @@ + + + + Phantom Migrations + <%= stylesheet_link_tag 'actual_db_schema/styles', media: 'all' %> + + +
+

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' %> + <% end %> +
+ <% if phantom_migrations.present? %> + + + + + + + + + + + + + <% phantom_migrations.each do |migration| %> + + + + + + + + + <% end %> + +
StatusMigration IDNameBranchDatabaseActions
<%= 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' %> +
+
+ <% else %> +

No phantom migrations found.

+ <% end %> +
+ + diff --git a/app/views/actual_db_schema/phantom_migrations/show.html.erb b/app/views/actual_db_schema/phantom_migrations/show.html.erb new file mode 100644 index 0000000..96681cf --- /dev/null +++ b/app/views/actual_db_schema/phantom_migrations/show.html.erb @@ -0,0 +1,45 @@ + + + + Phantom Migration Details + <%= stylesheet_link_tag 'actual_db_schema/styles', media: 'all' %> + + +
+

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] %>
+ +

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' %> +
+
+ + diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..3e5b1e3 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,18 @@ +# 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 +end diff --git a/lib/actual_db_schema.rb b/lib/actual_db_schema.rb index 314002a..b51cd45 100644 --- a/lib/actual_db_schema.rb +++ b/lib/actual_db_schema.rb @@ -1,10 +1,13 @@ # 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/store" require_relative "actual_db_schema/version" +require_relative "actual_db_schema/migration" +require_relative "actual_db_schema/migration_context" require_relative "actual_db_schema/patches/migration_proxy" require_relative "actual_db_schema/patches/migrator" require_relative "actual_db_schema/patches/migration_context" @@ -17,8 +20,6 @@ module ActualDbSchema raise NotImplementedError, "ActualDbSchema is only supported in Rails" unless defined?(Rails) - require "railtie" - class << self attr_accessor :config, :failed end @@ -26,7 +27,8 @@ class << self self.failed = [] self.config = { enabled: Rails.env.development?, - auto_rollback_disabled: ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present? + auto_rollback_disabled: ENV["ACTUAL_DB_SCHEMA_AUTO_ROLLBACK_DISABLED"].present?, + ui_enabled: Rails.env.development? || ENV["ACTUAL_DB_SCHEMA_UI_ENABLED"].present? } def self.migrated_folder @@ -58,17 +60,16 @@ def self.migrations_paths end end - def self.migration_filename(fullpath) - fullpath.split("/").last + 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.for_each_db_connection - configs = ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env) - configs.each do |db_config| - config = db_config.respond_to?(:config) ? db_config.config : db_config - ActiveRecord::Base.establish_connection(config) - yield - end + def self.migration_filename(fullpath) + fullpath.split("/").last end end diff --git a/lib/actual_db_schema/commands/base.rb b/lib/actual_db_schema/commands/base.rb index 055f35e..fc49684 100644 --- a/lib/actual_db_schema/commands/base.rb +++ b/lib/actual_db_schema/commands/base.rb @@ -4,6 +4,12 @@ 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." @@ -17,22 +23,6 @@ def call def call_impl raise NotImplementedError end - - def context - @context ||= fetch_migration_context.tap do |c| - c.extend(ActualDbSchema::Patches::MigrationContext) - end - end - - def fetch_migration_context - 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::Base.connection_pool.migration_context - else - ActiveRecord::Base.connection.migration_context - end - end end end end diff --git a/lib/actual_db_schema/commands/list.rb b/lib/actual_db_schema/commands/list.rb index a45f644..fe26ecc 100644 --- a/lib/actual_db_schema/commands/list.rb +++ b/lib/actual_db_schema/commands/list.rb @@ -12,27 +12,18 @@ def call_impl end def indexed_phantom_migrations - @indexed_phantom_migrations ||= context.migrations.index_by { |m| m.version.to_s } + @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." - database_path = db_config[:database] - puts "\ndatabase: #{database_path}\n\n" + puts "\ndatabase: #{ActualDbSchema.db_config[:database]}\n\n" puts header.join(" ") puts "-" * separator_width end - def 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 separator_width header.map(&:length).sum + (header.size - 1) * 2 end @@ -66,14 +57,14 @@ def line_for(status, version) ].join(" ") end - def branch_for(version) - metadata.fetch(version, {})[:branch] || "unknown" - 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" diff --git a/lib/actual_db_schema/commands/rollback.rb b/lib/actual_db_schema/commands/rollback.rb index e0fb7ef..3ea0850 100644 --- a/lib/actual_db_schema/commands/rollback.rb +++ b/lib/actual_db_schema/commands/rollback.rb @@ -4,9 +4,9 @@ module ActualDbSchema module Commands # Rolls back all phantom migrations class Rollback < Base - def initialize(manual_mode: false) + def initialize(context, manual_mode: false) @manual_mode = manual_mode || manual_mode_default? - super() + super(context) end private diff --git a/lib/actual_db_schema/engine.rb b/lib/actual_db_schema/engine.rb new file mode 100644 index 0000000..a245b27 --- /dev/null +++ b/lib/actual_db_schema/engine.rb @@ -0,0 +1,18 @@ +# 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 + + app.config.assets.precompile += %w[actual_db_schema/styles.css] + end + end + end +end diff --git a/lib/actual_db_schema/migration.rb b/lib/actual_db_schema/migration.rb new file mode 100644 index 0000000..473e172 --- /dev/null +++ b/lib/actual_db_schema/migration.rb @@ -0,0 +1,119 @@ +# 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, 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 + + 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) + ) + end + + def sort_migrations_desc(migrations) + migrations.sort_by { |migration| migration[:version].to_i }.reverse if migrations.any? + end + + def phantom?(migration) + migration.filename.include?("/tmp/migrated") + 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 + end +end diff --git a/lib/actual_db_schema/migration_context.rb b/lib/actual_db_schema/migration_context.rb new file mode 100644 index 0000000..dd5fd01 --- /dev/null +++ b/lib/actual_db_schema/migration_context.rb @@ -0,0 +1,37 @@ +# 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 + configs.each do |db_config| + establish_connection(db_config) + yield context + end + 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 configs + ActiveRecord::Base.configurations.configs_for(env_name: ActiveRecord::Tasks::DatabaseTasks.env) + 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 diff --git a/lib/actual_db_schema/patches/migration_context.rb b/lib/actual_db_schema/patches/migration_context.rb index 5645aac..ad01fbe 100644 --- a/lib/actual_db_schema/patches/migration_context.rb +++ b/lib/actual_db_schema/patches/migration_context.rb @@ -5,7 +5,7 @@ module Patches # Add new command to roll back the phantom migrations module MigrationContext def rollback_branches(manual_mode: false) - migrations.reverse_each do |migration| + phantom_migrations.reverse_each do |migration| next unless status_up?(migration) show_info_for(migration) if manual_mode @@ -15,6 +15,16 @@ def rollback_branches(manual_mode: false) end 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 down_migrator_for(migration) @@ -31,9 +41,13 @@ def migration_files paths = Array(migrations_paths) current_branch_files = Dir[*paths.flat_map { |path| "#{path}/**/[0-9]*_*.rb" }] other_branches_files = Dir["#{ActualDbSchema.migrated_folder}/**/[0-9]*_*.rb"] + 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_file_names = current_branch_files.map { |f| ActualDbSchema.migration_filename(f) } - other_branches_files.reject { |f| ActualDbSchema.migration_filename(f).in?(current_branch_file_names) } + current_branch_files + filtered_other_branches_files end def status_up?(migration) @@ -52,7 +66,7 @@ def show_info_for(migration) puts "\n[ActualDbSchema] A phantom migration was found and is about to be rolled back." puts "Please make a decision from the options below to proceed.\n\n" puts "Branch: #{branch_for(migration.version.to_s)}" - puts "Database: #{db_config[:database]}" + puts "Database: #{ActualDbSchema.db_config[:database]}" puts "Version: #{migration.version}\n\n" puts File.read(migration.filename) end @@ -69,14 +83,6 @@ def migrate(migration) migrator.migrate end - def db_config - @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 branch_for(version) metadata.fetch(version, {})[:branch] || "unknown" end diff --git a/lib/railtie.rb b/lib/railtie.rb deleted file mode 100644 index d64c247..0000000 --- a/lib/railtie.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require "rails" - -module ActualDbSchema - # Load the task into Rails app - class Railtie < Rails::Railtie - railtie_name :actual_db_schema - - rake_tasks do - path = File.expand_path(__dir__) - Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f } - end - end -end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake index 3379f06..2c42b8f 100644 --- a/lib/tasks/db.rake +++ b/lib/tasks/db.rake @@ -4,8 +4,8 @@ namespace :db do desc "Rollback migrations that were run inside not a merged branch." task rollback_branches: :load_config do ActualDbSchema.failed = [] - ActualDbSchema.for_each_db_connection do - ActualDbSchema::Commands::Rollback.new.call + ActualDbSchema::MigrationContext.instance.each do |context| + ActualDbSchema::Commands::Rollback.new(context).call end end @@ -13,16 +13,16 @@ namespace :db do desc "Manually rollback phantom migrations one by one" task manual: :load_config do ActualDbSchema.failed = [] - ActualDbSchema.for_each_db_connection do - ActualDbSchema::Commands::Rollback.new(manual_mode: true).call + 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.for_each_db_connection do - ActualDbSchema::Commands::List.new.call + ActualDbSchema::MigrationContext.instance.each do |context| + ActualDbSchema::Commands::List.new(context).call end end diff --git a/test/controllers/actual_db_schema/migrations_controller_test.rb b/test/controllers/actual_db_schema/migrations_controller_test.rb new file mode 100644 index 0000000..f810e4f --- /dev/null +++ b/test/controllers/actual_db_schema/migrations_controller_test.rb @@ -0,0 +1,118 @@ +# 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/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: "tmp/primary.sqlite3" + 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: "tmp/primary.sqlite3" + 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: "tmp/secondary.sqlite3" + 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: "tmp/secondary.sqlite3" + end + end + end + end + + test "GET #show returns a successful response" do + get :show, params: { id: "20130906111511", database: "tmp/primary.sqlite3" } + 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: "tmp/primary.sqlite3" + end + assert_select "tr" do + assert_select "th", text: "Branch" + assert_select "td", text: @utils.branch_for("20130906111511") + end + end + end + + test "GET #show returns a 404 response if migration not found" do + get :show, params: { id: "nil", database: "tmp/primary.sqlite3" } + 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: "tmp/primary.sqlite3" } + 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 + end + end +end diff --git a/test/controllers/actual_db_schema/phantom_migrations_controller_test.rb b/test/controllers/actual_db_schema/phantom_migrations_controller_test.rb new file mode 100644 index 0000000..2cb7738 --- /dev/null +++ b/test/controllers/actual_db_schema/phantom_migrations_controller_test.rb @@ -0,0 +1,145 @@ +# 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: "tmp/primary.sqlite3" + 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: "tmp/primary.sqlite3" + 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: "tmp/secondary.sqlite3" + 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: "tmp/secondary.sqlite3" + 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: "tmp/primary.sqlite3" } + 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: "tmp/primary.sqlite3" + end + assert_select "tr" do + assert_select "th", text: "Branch" + assert_select "td", text: @utils.branch_for("20130906111511") + end + end + end + + test "GET #show returns a 404 response if migration not found" do + get :show, params: { id: "nil", database: "tmp/primary.sqlite3" } + 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: "tmp/primary.sqlite3" } + 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 + 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 diff --git a/test/dummy_app/config/database.yml b/test/dummy_app/config/database.yml index de4e91d..e69de29 100644 --- a/test/dummy_app/config/database.yml +++ b/test/dummy_app/config/database.yml @@ -1,11 +0,0 @@ -test: - 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 %> diff --git a/test/dummy_app/db/secondary_schema.rb b/test/dummy_app/db/secondary_schema.rb deleted file mode 100644 index be9176e..0000000 --- a/test/dummy_app/db/secondary_schema.rb +++ /dev/null @@ -1,14 +0,0 @@ -# This file is auto-generated from the current state of the database. Instead -# of editing this file, please use the migrations feature of Active Record to -# incrementally modify your database, and then regenerate this schema definition. -# -# This file is the source Rails uses to define your schema when running `bin/rails -# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to -# be faster and is potentially less error prone than running all of your -# migrations from scratch. Old migrations may fail to apply correctly if those -# migrations use external dependencies or application code. -# -# It's strongly recommended that you check this file into your version control system. - -ActiveRecord::Schema[7.1].define(version: 2013_09_06_111515) do -end diff --git a/test/dummy_app/public/404.html b/test/dummy_app/public/404.html new file mode 100644 index 0000000..e69de29 diff --git a/test/rake_task_secondary_test.rb b/test/rake_task_secondary_test.rb index 6722333..2e2202c 100644 --- a/test/rake_task_secondary_test.rb +++ b/test/rake_task_secondary_test.rb @@ -8,6 +8,7 @@ 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"]) diff --git a/test/rake_task_test.rb b/test/rake_task_test.rb index 0bb464b..74550eb 100644 --- a/test/rake_task_test.rb +++ b/test/rake_task_test.rb @@ -6,6 +6,7 @@ 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"]) diff --git a/test/rake_tasks_all_databases_test.rb b/test/rake_tasks_all_databases_test.rb index aa8d393..46ffe90 100644 --- a/test/rake_tasks_all_databases_test.rb +++ b/test/rake_tasks_all_databases_test.rb @@ -11,6 +11,7 @@ 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) diff --git a/test/support/test_utils.rb b/test/support/test_utils.rb index c00d4b9..bbd3ea8 100644 --- a/test/support/test_utils.rb +++ b/test/support/test_utils.rb @@ -49,17 +49,6 @@ def applied_migrations(db_config = nil) end end - def clear_schema(db_config = nil) - if db_config - db_config.each_value do |config| - ActiveRecord::Base.establish_connection(**config) - clear_schema_call - end - else - clear_schema_call - end - end - def simulate_input(input) $stdin = StringIO.new("#{([input] * 999).join("\n")}\n") yield @@ -107,6 +96,16 @@ def down end end + def reset_database_yml(db_config) + database_yml_path = Rails.root.join("config", "database.yml") + File.delete(database_yml_path) if File.exist?(database_yml_path) + File.open(database_yml_path, "w") do |file| + file.write({ + "test" => db_config + }.to_yaml) + end + end + def prepare_phantom_migrations(db_config = nil) run_migrations if db_config @@ -141,14 +140,19 @@ def migrated_files(db_config = nil) end end + def branch_for(version) + metadata.fetch(version.to_s, {})[:branch] + end + private def cleanup_call(prefix_name = nil) delete_migrations_files(prefix_name) create_schema_migration_table - run_sql("delete from schema_migrations") + 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 @@ -189,4 +193,8 @@ def applied_migrations_call def run_sql(sql) ActiveRecord::Base.connection.execute(sql) end + + def metadata + ActualDbSchema::Store.instance.read + end end diff --git a/test/test_helper.rb b/test/test_helper.rb index 3d5f832..e79a785 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -5,6 +5,7 @@ require "actual_db_schema" require "minitest/autorun" require "debug" +require "rake" require "support/test_utils" Rails.env = "test"