diff --git a/README.md b/README.md index f184d77..7da8bc5 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,20 @@ This task will prompt you to choose one of the three options: Based on your selection, a post-checkout hook will be installed or updated in your `.git/hooks` folder. +## Multi-Tenancy Support + +If your application uses multiple schemas or tenants, you can configure ActualDbSchema to manage migrations across all schemas by adding the following to your initializer file (`config/initializers/actual_db_schema.rb`): + +```ruby +ActualDbSchema.config[:multi_tenant_schemas] = -> { # list of all active schemas } +``` + +### Example: + +```ruby +ActualDbSchema.config[:multi_tenant_schemas] = -> { ["public", "tenant1", "tenant2"] } +``` + ## 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. diff --git a/lib/actual_db_schema.rb b/lib/actual_db_schema.rb index ba3b112..b177ab2 100644 --- a/lib/actual_db_schema.rb +++ b/lib/actual_db_schema.rb @@ -14,6 +14,7 @@ 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/commands/base" require_relative "actual_db_schema/commands/rollback" diff --git a/lib/actual_db_schema/commands/rollback.rb b/lib/actual_db_schema/commands/rollback.rb index be71319..b154bdb 100644 --- a/lib/actual_db_schema/commands/rollback.rb +++ b/lib/actual_db_schema/commands/rollback.rb @@ -17,7 +17,7 @@ def initialize(context, manual_mode: false) def call_impl rolled_back = context.rollback_branches(manual_mode: @manual_mode) - return unless rolled_back + return unless rolled_back || ActualDbSchema.failed.any? ActualDbSchema.failed.empty? ? print_success : print_error end @@ -43,11 +43,10 @@ def print_error def failed_migrations_list ActualDbSchema.failed.map.with_index(1) do |failed, index| - <<~MIGRATION - #{colorize("Migration ##{index}:", :yellow)} - File: #{failed.short_filename} - Branch: #{failed.branch} - MIGRATION + 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 diff --git a/lib/actual_db_schema/failed_migration.rb b/lib/actual_db_schema/failed_migration.rb index 2c27e3f..995180c 100644 --- a/lib/actual_db_schema/failed_migration.rb +++ b/lib/actual_db_schema/failed_migration.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ActualDbSchema - FailedMigration = Struct.new(:migration, :exception, :branch, keyword_init: true) do + FailedMigration = Struct.new(:migration, :exception, :branch, :schema, keyword_init: true) do def filename migration.filename end diff --git a/lib/actual_db_schema/multi_tenant.rb b/lib/actual_db_schema/multi_tenant.rb new file mode 100644 index 0000000..21270f4 --- /dev/null +++ b/lib/actual_db_schema/multi_tenant.rb @@ -0,0 +1,63 @@ +# 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 diff --git a/lib/actual_db_schema/patches/migration_context.rb b/lib/actual_db_schema/patches/migration_context.rb index 85d879b..a221ebc 100644 --- a/lib/actual_db_schema/patches/migration_context.rb +++ b/lib/actual_db_schema/patches/migration_context.rb @@ -3,23 +3,21 @@ module ActualDbSchema module Patches # Add new command to roll back the phantom migrations - module MigrationContext + module MigrationContext # rubocop:disable Metrics/ModuleLength include ActualDbSchema::OutputFormatter def rollback_branches(manual_mode: false) - rolled_back = false + schemas = multi_tenant_schemas&.call || [] + schema_count = schemas.any? ? schemas.size : 1 - phantom_migrations.reverse_each do |migration| - next unless status_up?(migration) + rolled_back_migrations = if schemas.any? + rollback_multi_tenant(schemas, manual_mode: manual_mode) + else + rollback_branches_for_schema(manual_mode: manual_mode) + end - rolled_back = true - show_info_for(migration) if manual_mode - migrate(migration) if !manual_mode || user_wants_rollback? - rescue StandardError => e - handle_rollback_error(migration, e) - end - - rolled_back + delete_migrations(rolled_back_migrations, schema_count) + rolled_back_migrations.any? end def phantom_migrations @@ -34,6 +32,32 @@ def phantom_migrations 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 + migrate(migration, rolled_back_migrations, schema_name) if !manual_mode || user_wants_rollback? + 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) @@ -69,26 +93,29 @@ def user_wants_rollback? answer[0] == "y" end - def show_info_for(migration) + 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) + def migrate(migration, rolled_back_migrations, schema_name = nil) migration.name = extract_class_name(migration.filename) - message = "[ActualDbSchema] Rolling back phantom migration #{migration.version} #{migration.name} " \ - "(from 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_for(migration.version.to_s)})" puts colorize(message, :gray) migrator = down_migrator_for(migration) migrator.extend(ActualDbSchema::Patches::Migrator) migrator.migrate - File.delete(migration.filename) + rolled_back_migrations << migration end def extract_class_name(filename) @@ -104,7 +131,7 @@ def metadata @metadata ||= ActualDbSchema::Store.instance.read end - def handle_rollback_error(migration, exception) + def handle_rollback_error(migration, exception, schema_name = nil) error_message = <<~ERROR Error encountered during rollback: @@ -115,7 +142,8 @@ def handle_rollback_error(migration, exception) ActualDbSchema.failed << FailedMigration.new( migration: migration, exception: exception, - branch: branch_for(migration.version.to_s) + branch: branch_for(migration.version.to_s), + schema: schema_name ) end @@ -127,6 +155,21 @@ def cleaned_exception_message(message) patterns_to_remove.reduce(message.strip) { |msg, pattern| msg.gsub(pattern, "").strip } end + + def delete_migrations(migrations, schema_count) + migration_counts = migrations.each_with_object(Hash.new(0)) do |migration, hash| + hash[migration.filename] += 1 + end + + migrations.uniq.each do |migration| + count = migration_counts[migration.filename] + File.delete(migration.filename) if count == schema_count && File.exist?(migration.filename) + end + end + + def multi_tenant_schemas + ActualDbSchema.config[:multi_tenant_schemas] + end end end end diff --git a/test/rake_task_multi_tenant_test.rb b/test/rake_task_multi_tenant_test.rb new file mode 100644 index 0000000..27dce89 --- /dev/null +++ b/test/rake_task_multi_tenant_test.rb @@ -0,0 +1,170 @@ +# 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] } + puts "[Debug] PostgreSQL Schemas: #{ActiveRecord::Base.connection.execute("SELECT schema_name FROM information_schema.schemata").map { |row| row['schema_name'] }}" + 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 diff --git a/test/support/test_utils.rb b/test/support/test_utils.rb index 1f945d1..933713b 100644 --- a/test/support/test_utils.rb +++ b/test/support/test_utils.rb @@ -33,9 +33,13 @@ def remove_app_dir(name) end def run_migrations - Rake::Task["db:migrate"].invoke - Rake::Task["db:migrate"].reenable - Rake::Task["db:rollback_branches"].reenable + schemas = ActualDbSchema.config[:multi_tenant_schemas]&.call + puts "[Debug] Schemas: #{schemas}" + 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) @@ -160,6 +164,12 @@ def secondary_database private + def run_migration_tasks + 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