Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add multi-tenancy support #114

Merged
merged 1 commit into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 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
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
Loading