Skip to content
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
6 changes: 6 additions & 0 deletions lib/active_record/tenanted.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

require "zeitwerk"
loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord)
loader.inflector.inflect(
"sqlite" => "SQLite",
)
loader.setup

module ActiveRecord
Expand Down Expand Up @@ -35,6 +38,9 @@ class TenantDoesNotExistError < Error; end
# Raised when the Rails integration is being invoked but has not been configured.
class IntegrationNotConfiguredError < Error; end

# Raised when an unsupported database adapter is used.
class UnsupportedDatabaseError < Error; end

def self.connection_class
# TODO: cache this / speed this up
Rails.application.config.active_record_tenanted.connection_class&.constantize
Expand Down
53 changes: 53 additions & 0 deletions lib/active_record/tenanted/database_adapter.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

module ActiveRecord
module Tenanted
class DatabaseAdapter # :nodoc:
ADAPTERS = {
"sqlite3" => "ActiveRecord::Tenanted::DatabaseAdapters::SQLite",
}.freeze

class << self
def create_database(db_config)
adapter_for(db_config).create_database
end

def drop_database(db_config)
adapter_for(db_config).drop_database
end

def database_exist?(db_config)
adapter_for(db_config).database_exist?
end

def database_ready?(db_config)
adapter_for(db_config).database_ready?
end

def acquire_ready_lock(db_config, &block)
adapter_for(db_config).acquire_ready_lock(db_config, &block)
end

def tenant_databases(db_config)
adapter_for(db_config).tenant_databases
end

def validate_tenant_name(db_config, tenant_name)
adapter_for(db_config).validate_tenant_name(tenant_name)
end

def adapter_for(db_config)
adapter_class_name = ADAPTERS[db_config.adapter]

if adapter_class_name.nil?
raise ActiveRecord::Tenanted::UnsupportedDatabaseError,
"Unsupported database adapter for tenanting: #{db_config.adapter}. " \
"Supported adapters: #{ADAPTERS.keys.join(', ')}"
end

adapter_class_name.constantize.new(db_config)
end
end
end
end
end
67 changes: 67 additions & 0 deletions lib/active_record/tenanted/database_adapters/sqlite.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

module ActiveRecord
module Tenanted
module DatabaseAdapters
class SQLite # :nodoc:
def initialize(db_config)
@db_config = db_config
end

def create_database
# Ensure the directory exists
database_dir = File.dirname(database_path)
FileUtils.mkdir_p(database_dir) unless File.directory?(database_dir)

# Create the SQLite database file
FileUtils.touch(database_path)
end

def drop_database
# Remove the SQLite database file and associated files
FileUtils.rm_f(database_path)
FileUtils.rm_f("#{database_path}-wal") # Write-Ahead Logging file
FileUtils.rm_f("#{database_path}-shm") # Shared Memory file
end

def database_exist?
File.exist?(database_path)
end

def database_ready?
File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
end

def tenant_databases
glob = db_config.database_path_for("*")
scanner = Regexp.new(db_config.database_path_for("(.+)"))

Dir.glob(glob).filter_map do |path|
result = path.scan(scanner).flatten.first
if result.nil?
Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}"
end
result
end
end

def acquire_ready_lock(db_config, &block)
ActiveRecord::Tenanted::Mutex::Ready.lock(database_path, &block)
end

def validate_tenant_name(tenant_name)
if tenant_name.match?(%r{[/'"`]})
raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
end
end

private
attr_reader :db_config

def database_path
db_config.database_path
end
end
end
end
end
16 changes: 2 additions & 14 deletions lib/active_record/tenanted/database_configurations/base_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,7 @@ def database_path_for(tenant_name)
end

def tenants
glob = database_path_for("*")
scanner = Regexp.new(database_path_for("(.+)"))

Dir.glob(glob).map do |path|
result = path.scan(scanner).flatten.first
if result.nil?
warn "WARN: ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}. " \
"This is a bug, please report it to https://github.com/basecamp/activerecord-tenanted/issues"
end
result
end
ActiveRecord::Tenanted::DatabaseAdapter.tenant_databases(self)
end

def new_tenant_config(tenant_name)
Expand Down Expand Up @@ -85,9 +75,7 @@ def coerce_path(path)
end

def validate_tenant_name(tenant_name)
if tenant_name.match?(%r{[/'"`]})
raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
end
ActiveRecord::Tenanted::DatabaseAdapter.validate_tenant_name(self, tenant_name)
end

def test_worker_path(path)
Expand Down
10 changes: 3 additions & 7 deletions lib/active_record/tenanted/database_tasks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,9 @@ def drop_all
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config

root_config.tenants.each do |tenant|
# NOTE: This is obviously a sqlite-specific implementation.
# TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
# Then this would delegate to the adapter and become adapter-agnostic.
root_config.database_path_for(tenant).tap do |path|
FileUtils.rm(path)
$stdout.puts "Dropped database '#{path}'" if verbose?
end
db_config = root_config.new_tenant_config(tenant)
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
$stdout.puts "Dropped database '#{db_config.database_path}'" if verbose?
end
end

Expand Down
36 changes: 15 additions & 21 deletions lib/active_record/tenanted/tenant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,8 @@ def current_tenant=(tenant_name)
end

def tenant_exist?(tenant_name)
# this will have to be an adapter-specific implementation if we support other than sqlite
database_path = tenanted_root_config.database_path_for(tenant_name)

File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
db_config = tenanted_root_config.new_tenant_config(tenant_name)
ActiveRecord::Tenanted::DatabaseAdapter.database_ready?(db_config)
end

def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
Expand All @@ -123,14 +121,12 @@ def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)

def create_tenant(tenant_name, if_not_exists: false, &block)
created_db = false
database_path = tenanted_root_config.database_path_for(tenant_name)
db_config = tenanted_root_config.new_tenant_config(tenant_name)

ActiveRecord::Tenanted::DatabaseAdapter.acquire_ready_lock(db_config) do
unless ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(db_config)

ActiveRecord::Tenanted::Mutex::Ready.lock(database_path) do
unless File.exist?(database_path)
# NOTE: This is obviously a sqlite-specific implementation.
# TODO: Add a `create_database` method upstream in the sqlite3 adapter, and call it.
# Then this would delegate to the adapter and become adapter-agnostic.
FileUtils.touch(database_path)
ActiveRecord::Tenanted::DatabaseAdapter.create_database(db_config)

with_tenant(tenant_name) do
connection_pool(schema_version_check: false)
Expand All @@ -140,7 +136,7 @@ def create_tenant(tenant_name, if_not_exists: false, &block)
created_db = true
end
rescue
FileUtils.rm_f(database_path)
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
raise
end

Expand All @@ -160,10 +156,8 @@ def destroy_tenant(tenant_name)
end
end

# NOTE: This is obviously a sqlite-specific implementation.
# TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
# Then this would delegate to the adapter and become adapter-agnostic.
FileUtils.rm_f(tenanted_root_config.database_path_for(tenant_name))
db_config = tenanted_root_config.new_tenant_config(tenant_name)
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
end

def tenants
Expand Down Expand Up @@ -213,12 +207,12 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc:
return superclass._create_tenanted_pool unless connection_class?

tenant = current_tenant
unless File.exist?(tenanted_root_config.database_path_for(tenant))
raise TenantDoesNotExistError, "The database file for tenant #{tenant.inspect} does not exist."
end
db_config = tenanted_root_config.new_tenant_config(tenant)

config = tenanted_root_config.new_tenant_config(tenant)
pool = establish_connection(config)
unless ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(db_config)
raise TenantDoesNotExistError, "The database for tenant #{tenant.inspect} does not exist."
end
pool = establish_connection(db_config)

if schema_version_check
pending_migrations = pool.migration_context.open.pending_migrations
Expand Down
122 changes: 122 additions & 0 deletions test/unit/database_adapter_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# frozen_string_literal: true

require "test_helper"

describe ActiveRecord::Tenanted::DatabaseAdapter do
describe ".adapter_for" do
test "selects correct adapter for sqlite3" do
adapter = ActiveRecord::Tenanted::DatabaseAdapter.adapter_for(create_config("sqlite3"))
assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::SQLite, adapter
end

test "raises error for unsupported adapter" do
unsupported_config = create_config("mongodb")

error = assert_raises ActiveRecord::Tenanted::UnsupportedDatabaseError do
ActiveRecord::Tenanted::DatabaseAdapter.adapter_for(unsupported_config)
end

assert_includes error.message, "Unsupported database adapter for tenanting: mongodb."
end
end

describe "delegation" do
ActiveRecord::Tenanted::DatabaseAdapter::ADAPTERS.each do |adapter, adapter_class_name|
test "#{adapter} .create_database calls adapter's #create_database" do
adapter_mock = Minitest::Mock.new
adapter_mock.expect(:create_database, nil)

adapter_class_name.constantize.stub(:new, adapter_mock) do
ActiveRecord::Tenanted::DatabaseAdapter.create_database(create_config(adapter))
end

assert_mock adapter_mock
end

test "#{adapter} .drop_database calls adapter's #drop_database" do
adapter_mock = Minitest::Mock.new
adapter_mock.expect(:drop_database, nil)

adapter_class_name.constantize.stub(:new, adapter_mock) do
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(create_config(adapter))
end

assert_mock adapter_mock
end

test "#{adapter} .database_exist? calls adapter's #database_exist?" do
adapter_mock = Minitest::Mock.new
adapter_mock.expect(:database_exist?, true)

result = adapter_class_name.constantize.stub(:new, adapter_mock) do
ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(create_config(adapter))
end

assert_equal true, result
assert_mock adapter_mock
end

test "#{adapter} .database_ready? calls adapter's #database_ready?" do
adapter_mock = Minitest::Mock.new
adapter_mock.expect(:database_ready?, true)

result = adapter_class_name.constantize.stub(:new, adapter_mock) do
ActiveRecord::Tenanted::DatabaseAdapter.database_ready?(create_config(adapter))
end

assert_equal true, result
assert_mock adapter_mock
end

test "#{adapter} .tenant_databases calls adapter's #tenant_databases" do
adapter_mock = Minitest::Mock.new
adapter_mock.expect(:tenant_databases, [ "foo", "bar" ])

result = adapter_class_name.constantize.stub(:new, adapter_mock) do
ActiveRecord::Tenanted::DatabaseAdapter.tenant_databases(create_config(adapter))
end

assert_equal [ "foo", "bar" ], result
assert_mock adapter_mock
end

test "#{adapter} .validate_tenant_name calls adapter's #validate_tenant_name" do
adapter_mock = Minitest::Mock.new
adapter_mock.expect(:validate_tenant_name, nil, [ "tenant1" ])

adapter_class_name.constantize.stub(:new, adapter_mock) do
ActiveRecord::Tenanted::DatabaseAdapter.validate_tenant_name(create_config(adapter), "tenant1")
end

assert_mock adapter_mock
end

test "#{adapter} .acquire_ready_lock calls adapter's #acquire_ready_lock" do
fake_adapter = Object.new
fake_adapter.define_singleton_method(:acquire_ready_lock) do |id, &blk|
blk&.call
end

yielded = false
result = adapter_class_name.constantize.stub(:new, fake_adapter) do
ActiveRecord::Tenanted::DatabaseAdapter.acquire_ready_lock(create_config(adapter)) { yielded = true; :ok }
end

assert_equal true, yielded
assert_equal :ok, result
end
end
end

private
def create_config(adapter)
ActiveRecord::DatabaseConfigurations::HashConfig.new(
"test",
"test_config",
{
adapter: adapter,
database: "db_name",
}
)
end
end
5 changes: 1 addition & 4 deletions test/unit/database_tasks_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@
describe ".migrate_tenant" do
for_each_scenario do
setup do
# TODO: This should really be a create_database method on the sqlite3 adapter, see the notes
# in Tenant.create_tenant.
FileUtils.mkdir_p(File.dirname(tenanted_config.database_path_for("foo")))
FileUtils.touch(tenanted_config.database_path_for("foo"))
ActiveRecord::Tenanted::DatabaseAdapter.create_database(tenanted_config.new_tenant_config("foo"))
end

test "database should be created" do
Expand Down