Skip to content

Commit

Permalink
Add multi-tenancy support
Browse files Browse the repository at this point in the history
  • Loading branch information
m-darbinyan committed Jan 9, 2025
1 parent 5607d6b commit 7ed6e1c
Show file tree
Hide file tree
Showing 8 changed files with 328 additions and 29 deletions.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions lib/actual_db_schema.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 5 additions & 6 deletions lib/actual_db_schema/commands/rollback.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
2 changes: 1 addition & 1 deletion lib/actual_db_schema/failed_migration.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
63 changes: 63 additions & 0 deletions lib/actual_db_schema/multi_tenant.rb
Original file line number Diff line number Diff line change
@@ -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
81 changes: 62 additions & 19 deletions lib/actual_db_schema/patches/migration_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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

Expand All @@ -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
Loading

0 comments on commit 7ed6e1c

Please sign in to comment.