Skip to content

Commit a1fdb5d

Browse files
authored
Move database path handling from BaseConfig into the SQLite adapter (#215)
In #204 and #214 we discussed that "path" methods don't belong in `DatabaseConfigurations::BaseConfig` because the concept of a file path is SQLite-specific. This PR moves path concerns out of BaseConfig and into the SQLite adapter. There are a few other notable changes in here, too, though: - I've removed the adapter delegation layer, and instead the BaseConfig and TenantConfig classes will create the adapter on demand and memoize it, which should mean fewer objects are being allocated - Dropped the unnecessary `db_config` argument to `acquire_ready_lock` As I noted in the comment in `database_adapters/sqlite.rb`, I'm not entirely satisfied with how we've extracted the SQLite-specific behavior, because some methods feel like they should be in a database-specific subclass of BaseConfig, and the rest feel like they should be in the Rails database connection adapter. So I think we should continue to refactor this as the opportunity arises! cc @andrewmarkle
2 parents 3ce9b33 + cd0fb68 commit a1fdb5d

File tree

14 files changed

+295
-430
lines changed

14 files changed

+295
-430
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
### Changed
66

77
- The return value from an Active Record model `#cache_key` has changed from `users/1?tenant=foo` to `foo/users/1`. For existing applications, this will invalidate any relevant cache entries. #187 @miguelmarcondesf
8+
- Renamed `ActiveRecord::Tenanted::DatabaseTasks.tenanted_config` to `.base_config`.
89

910

1011
### Improved

lib/active_record/tenanted/database_adapter.rb

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,35 +8,7 @@ class DatabaseAdapter # :nodoc:
88
}.freeze
99

1010
class << self
11-
def create_database(db_config)
12-
adapter_for(db_config).create_database
13-
end
14-
15-
def drop_database(db_config)
16-
adapter_for(db_config).drop_database
17-
end
18-
19-
def database_exist?(db_config)
20-
adapter_for(db_config).database_exist?
21-
end
22-
23-
def database_ready?(db_config)
24-
adapter_for(db_config).database_ready?
25-
end
26-
27-
def acquire_ready_lock(db_config, &block)
28-
adapter_for(db_config).acquire_ready_lock(db_config, &block)
29-
end
30-
31-
def tenant_databases(db_config)
32-
adapter_for(db_config).tenant_databases
33-
end
34-
35-
def validate_tenant_name(db_config, tenant_name)
36-
adapter_for(db_config).validate_tenant_name(tenant_name)
37-
end
38-
39-
def adapter_for(db_config)
11+
def new(db_config)
4012
adapter_class_name = ADAPTERS[db_config.adapter]
4113

4214
if adapter_class_name.nil?

lib/active_record/tenanted/database_adapters/sqlite.rb

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,46 @@
22

33
module ActiveRecord
44
module Tenanted
5-
module DatabaseAdapters
6-
class SQLite # :nodoc:
5+
module DatabaseAdapters # :nodoc:
6+
#
7+
# TODO: This still feels to me like it's not _quite_ right. I think we could further refactor this by:
8+
#
9+
# 1. Moving tenant_databases and validate_tenant_name to BaseConfig, and subclassing it for
10+
# each database
11+
# 2. Moving create_database, drop_database, database_exist?, database_ready?,
12+
# acquire_ready_lock, ensure_database_directory_exists, and database_path to the SQLite
13+
# connection adapter, possibly into Rails
14+
# 3. Moving test_workerize and path_for to be SQLite connection adapter class methods,
15+
# possibly into Rails
16+
#
17+
class SQLite
18+
attr_reader :db_config
19+
720
def initialize(db_config)
821
@db_config = db_config
922
end
1023

11-
def create_database
12-
# Ensure the directory exists
13-
database_dir = File.dirname(database_path)
14-
FileUtils.mkdir_p(database_dir) unless File.directory?(database_dir)
24+
def tenant_databases
25+
glob = path_for(db_config.database_for("*"))
26+
scanner = Regexp.new(path_for(db_config.database_for("(.+)")))
27+
28+
Dir.glob(glob).filter_map do |path|
29+
result = path.scan(scanner).flatten.first
30+
if result.nil?
31+
Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}"
32+
end
33+
result
34+
end
35+
end
36+
37+
def validate_tenant_name(tenant_name)
38+
if tenant_name.match?(%r{[/'"`]})
39+
raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
40+
end
41+
end
1542

16-
# Create the SQLite database file
43+
def create_database
44+
ensure_database_directory_exists
1745
FileUtils.touch(database_path)
1846
end
1947

@@ -32,35 +60,45 @@ def database_ready?
3260
File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
3361
end
3462

35-
def tenant_databases
36-
glob = db_config.database_path_for("*")
37-
scanner = Regexp.new(db_config.database_path_for("(.+)"))
63+
def acquire_ready_lock(&block)
64+
ActiveRecord::Tenanted::Mutex::Ready.lock(database_path, &block)
65+
end
3866

39-
Dir.glob(glob).filter_map do |path|
40-
result = path.scan(scanner).flatten.first
41-
if result.nil?
42-
Rails.logger.warn "ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}"
43-
end
44-
result
67+
def ensure_database_directory_exists
68+
return unless database_path
69+
70+
database_dir = File.dirname(database_path)
71+
unless File.directory?(database_dir)
72+
FileUtils.mkdir_p(database_dir)
4573
end
4674
end
4775

48-
def acquire_ready_lock(db_config, &block)
49-
ActiveRecord::Tenanted::Mutex::Ready.lock(database_path, &block)
76+
def database_path
77+
path_for(db_config.database)
5078
end
5179

52-
def validate_tenant_name(tenant_name)
53-
if tenant_name.match?(%r{[/'"`]})
54-
raise BadTenantNameError, "Tenant name contains an invalid character: #{tenant_name.inspect}"
80+
def test_workerize(db, test_worker_id)
81+
test_worker_suffix = "_#{test_worker_id}"
82+
83+
if db.start_with?("file:") && db.include?("?")
84+
db.sub(/(\?.*)$/, "#{test_worker_suffix}\\1")
85+
else
86+
db + test_worker_suffix
5587
end
5688
end
5789

58-
private
59-
attr_reader :db_config
60-
61-
def database_path
62-
db_config.database_path
90+
# A sqlite database path can be a file path or a URI (either relative or absolute). We
91+
# can't parse it as a standard URI in all circumstances, though, see
92+
# https://sqlite.org/uri.html
93+
def path_for(database)
94+
if database.start_with?("file:/")
95+
URI.parse(database).path
96+
elsif database.start_with?("file:")
97+
URI.parse(database.sub(/\?.*$/, "")).opaque
98+
else
99+
database
63100
end
101+
end
64102
end
65103
end
66104
end

lib/active_record/tenanted/database_configurations/base_config.rb

Lines changed: 10 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ class BaseConfig < ActiveRecord::DatabaseConfigurations::HashConfig
1111
def initialize(...)
1212
super
1313
@test_worker_id = nil
14+
@config_adapter = nil
15+
end
16+
17+
def config_adapter
18+
@config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self)
1419
end
1520

1621
def database_tasks?
@@ -20,31 +25,26 @@ def database_tasks?
2025
def database_for(tenant_name)
2126
tenant_name = tenant_name.to_s
2227

23-
validate_tenant_name(tenant_name)
28+
config_adapter.validate_tenant_name(tenant_name)
2429

25-
path = sprintf(database, tenant: tenant_name)
30+
db = sprintf(database, tenant: tenant_name)
2631

2732
if test_worker_id
28-
test_worker_path(path)
29-
else
30-
path
33+
db = config_adapter.test_workerize(db, test_worker_id)
3134
end
32-
end
3335

34-
def database_path_for(tenant_name)
35-
coerce_path(database_for(tenant_name))
36+
db
3637
end
3738

3839
def tenants
39-
ActiveRecord::Tenanted::DatabaseAdapter.tenant_databases(self)
40+
config_adapter.tenant_databases
4041
end
4142

4243
def new_tenant_config(tenant_name)
4344
config_name = "#{name}_#{tenant_name}"
4445
config_hash = configuration_hash.dup.tap do |hash|
4546
hash[:tenant] = tenant_name
4647
hash[:database] = database_for(tenant_name)
47-
hash[:database_path] = database_path_for(tenant_name)
4848
hash[:tenanted_config_name] = name
4949
end
5050
Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash)
@@ -60,33 +60,6 @@ def new_connection
6060
def max_connection_pools
6161
(configuration_hash[:max_connection_pools] || DEFAULT_MAX_CONNECTION_POOLS).to_i
6262
end
63-
64-
private
65-
# A sqlite database path can be a file path or a URI (either relative or absolute).
66-
# We can't parse it as a standard URI in all circumstances, though, see https://sqlite.org/uri.html
67-
def coerce_path(path)
68-
if path.start_with?("file:/")
69-
URI.parse(path).path
70-
elsif path.start_with?("file:")
71-
URI.parse(path.sub(/\?.*$/, "")).opaque
72-
else
73-
path
74-
end
75-
end
76-
77-
def validate_tenant_name(tenant_name)
78-
ActiveRecord::Tenanted::DatabaseAdapter.validate_tenant_name(self, tenant_name)
79-
end
80-
81-
def test_worker_path(path)
82-
test_worker_suffix = "_#{test_worker_id}"
83-
84-
if path.start_with?("file:") && path.include?("?")
85-
path.sub(/(\?.*)$/, "#{test_worker_suffix}\\1")
86-
else
87-
path + test_worker_suffix
88-
end
89-
end
9063
end
9164
end
9265
end

lib/active_record/tenanted/database_configurations/tenant_config.rb

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,24 @@ module ActiveRecord
44
module Tenanted
55
module DatabaseConfigurations
66
class TenantConfig < ActiveRecord::DatabaseConfigurations::HashConfig
7+
def initialize(...)
8+
super
9+
@config_adapter = nil
10+
end
11+
712
def tenant
813
configuration_hash.fetch(:tenant)
914
end
1015

16+
def config_adapter
17+
@config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self)
18+
end
19+
1120
def new_connection
12-
ensure_database_directory_exists # adapter doesn't handle this if the database is a URI
21+
# TODO: The Rails SQLite adapter doesn't handle directory creation for file: URIs. I would
22+
# like to fix that upstream, and remove this line.
23+
config_adapter.ensure_database_directory_exists
24+
1325
super.tap { |conn| conn.tenant = tenant }
1426
end
1527

@@ -36,20 +48,6 @@ def default_schema_cache_path(db_dir = "db")
3648
File.join(db_dir, "#{tenanted_config_name}_schema_cache.yml")
3749
end
3850
end
39-
40-
def database_path
41-
configuration_hash[:database_path]
42-
end
43-
44-
private
45-
def ensure_database_directory_exists
46-
return unless database_path
47-
48-
database_dir = File.dirname(database_path)
49-
unless File.directory?(database_dir)
50-
FileUtils.mkdir_p(database_dir)
51-
end
52-
end
5351
end
5452
end
5553
end

lib/active_record/tenanted/database_tasks.rb

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,34 @@ module DatabaseTasks # :nodoc:
66
extend self
77

88
def migrate_all
9-
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
9+
raise ArgumentError, "Could not find a tenanted database" unless config = base_config
1010

11-
tenants = root_config.tenants.presence || [ get_current_tenant ].compact
11+
tenants = config.tenants.presence || [ get_current_tenant ].compact
1212
tenants.each do |tenant|
13-
tenant_config = root_config.new_tenant_config(tenant)
13+
tenant_config = config.new_tenant_config(tenant)
1414
migrate(tenant_config)
1515
end
1616
end
1717

1818
def migrate_tenant(tenant_name = set_current_tenant)
19-
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
19+
raise ArgumentError, "Could not find a tenanted database" unless config = base_config
2020

21-
tenant_config = root_config.new_tenant_config(tenant_name)
21+
tenant_config = config.new_tenant_config(tenant_name)
2222

2323
migrate(tenant_config)
2424
end
2525

2626
def drop_all
27-
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
27+
raise ArgumentError, "Could not find a tenanted database" unless config = base_config
2828

29-
root_config.tenants.each do |tenant|
30-
db_config = root_config.new_tenant_config(tenant)
31-
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
32-
$stdout.puts "Dropped database '#{db_config.database_path}'" if verbose?
29+
config.tenants.each do |tenant|
30+
db_config = config.new_tenant_config(tenant)
31+
db_config.config_adapter.drop_database
32+
$stdout.puts "Dropped database '#{db_config.database}'" if verbose?
3333
end
3434
end
3535

36-
def root_database_config
36+
def base_config
3737
db_configs = ActiveRecord::Base.configurations.configs_for(
3838
env_name: ActiveRecord::Tasks::DatabaseTasks.env,
3939
include_hidden: true

lib/active_record/tenanted/tenant.rb

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,7 @@ def current_tenant=(tenant_name)
101101
end
102102

103103
def tenant_exist?(tenant_name)
104-
db_config = tenanted_root_config.new_tenant_config(tenant_name)
105-
ActiveRecord::Tenanted::DatabaseAdapter.database_ready?(db_config)
104+
tenanted_root_config.new_tenant_config(tenant_name).config_adapter.database_ready?
106105
end
107106

108107
def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
@@ -121,12 +120,11 @@ def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
121120

122121
def create_tenant(tenant_name, if_not_exists: false, &block)
123122
created_db = false
124-
db_config = tenanted_root_config.new_tenant_config(tenant_name)
123+
adapter = tenanted_root_config.new_tenant_config(tenant_name).config_adapter
125124

126-
ActiveRecord::Tenanted::DatabaseAdapter.acquire_ready_lock(db_config) do
127-
unless ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(db_config)
128-
129-
ActiveRecord::Tenanted::DatabaseAdapter.create_database(db_config)
125+
adapter.acquire_ready_lock do
126+
unless adapter.database_exist?
127+
adapter.create_database
130128

131129
with_tenant(tenant_name) do
132130
connection_pool(schema_version_check: false)
@@ -136,7 +134,7 @@ def create_tenant(tenant_name, if_not_exists: false, &block)
136134
created_db = true
137135
end
138136
rescue
139-
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
137+
adapter.drop_database
140138
raise
141139
end
142140

@@ -156,8 +154,7 @@ def destroy_tenant(tenant_name)
156154
end
157155
end
158156

159-
db_config = tenanted_root_config.new_tenant_config(tenant_name)
160-
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
157+
tenanted_root_config.new_tenant_config(tenant_name).config_adapter.drop_database
161158
end
162159

163160
def tenants
@@ -209,7 +206,7 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc:
209206
tenant = current_tenant
210207
db_config = tenanted_root_config.new_tenant_config(tenant)
211208

212-
unless ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(db_config)
209+
unless db_config.config_adapter.database_exist?
213210
raise TenantDoesNotExistError, "The database for tenant #{tenant.inspect} does not exist."
214211
end
215212
pool = establish_connection(db_config)

0 commit comments

Comments
 (0)