Skip to content

Commit 2cd6adc

Browse files
authored
Extract SQLite into an adapter (#204)
### New pattern that makes it possible to support multiple database adapters This extracts anything SQLite-specific into a generic database adapter class which forwards to a SQLite adapter class. This is a new pattern that will make it pretty easy to support different databases (I've got MySQL working in a different branch). Since this is a big change I wanted to validate the approach first and get this merged in for SQLite. But adding support for MYSQL is fairly trivial after this change. This pattern is very similar to how Rails does it. In Rails there's the main [ActiveRecord::Tasks::DatabaseTasks](https://github.com/rails/rails/blob/main/activerecord/lib/active_record/tasks/database_tasks.rb) which defines the API and then forwards the implementation over to the specific adapter to do the work. For example here's the class for [SQLite](https://github.com/rails/rails/blob/main/activerecord/lib/active_record/tasks/sqlite_database_tasks.rb). Originally I tried tackling this by using the adapters / methods that are provided by Rails but...it's just slightly different enough that it won't work as is. I think there's a world where we could upstream a bunch of these changes to into Rails and make it more compatible and/or make this gem more compatible for how Rails does it. Either way I thought it best to just do our own thing for now and cross that bridge another day. 😊 ### No breaking changes There should be no actual changes from the current implementation for SQLite—except for one place when we drop the db (I'll add a comment at that line of code). ### How I tested I relied on the test suite for this one and extracted everything without breaking any tests. I also added tests for the adapter to ensure that it forwards everything over to the correct adapter methods. I didn't unit test the actual SQLite adapter since this is well covered by other tests but I would be happy to add those as well.
2 parents 300f4cb + 3f861fe commit 2cd6adc

File tree

8 files changed

+269
-46
lines changed

8 files changed

+269
-46
lines changed

lib/active_record/tenanted.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
require "zeitwerk"
66
loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord)
7+
loader.inflector.inflect(
8+
"sqlite" => "SQLite",
9+
)
710
loader.setup
811

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

41+
# Raised when an unsupported database adapter is used.
42+
class UnsupportedDatabaseError < Error; end
43+
3844
def self.connection_class
3945
# TODO: cache this / speed this up
4046
Rails.application.config.active_record_tenanted.connection_class&.constantize
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord
4+
module Tenanted
5+
class DatabaseAdapter # :nodoc:
6+
ADAPTERS = {
7+
"sqlite3" => "ActiveRecord::Tenanted::DatabaseAdapters::SQLite",
8+
}.freeze
9+
10+
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)
40+
adapter_class_name = ADAPTERS[db_config.adapter]
41+
42+
if adapter_class_name.nil?
43+
raise ActiveRecord::Tenanted::UnsupportedDatabaseError,
44+
"Unsupported database adapter for tenanting: #{db_config.adapter}. " \
45+
"Supported adapters: #{ADAPTERS.keys.join(', ')}"
46+
end
47+
48+
adapter_class_name.constantize.new(db_config)
49+
end
50+
end
51+
end
52+
end
53+
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
module ActiveRecord
4+
module Tenanted
5+
module DatabaseAdapters
6+
class SQLite # :nodoc:
7+
def initialize(db_config)
8+
@db_config = db_config
9+
end
10+
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)
15+
16+
# Create the SQLite database file
17+
FileUtils.touch(database_path)
18+
end
19+
20+
def drop_database
21+
# Remove the SQLite database file and associated files
22+
FileUtils.rm_f(database_path)
23+
FileUtils.rm_f("#{database_path}-wal") # Write-Ahead Logging file
24+
FileUtils.rm_f("#{database_path}-shm") # Shared Memory file
25+
end
26+
27+
def database_exist?
28+
File.exist?(database_path)
29+
end
30+
31+
def database_ready?
32+
File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
33+
end
34+
35+
def tenant_databases
36+
glob = db_config.database_path_for("*")
37+
scanner = Regexp.new(db_config.database_path_for("(.+)"))
38+
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
45+
end
46+
end
47+
48+
def acquire_ready_lock(db_config, &block)
49+
ActiveRecord::Tenanted::Mutex::Ready.lock(database_path, &block)
50+
end
51+
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}"
55+
end
56+
end
57+
58+
private
59+
attr_reader :db_config
60+
61+
def database_path
62+
db_config.database_path
63+
end
64+
end
65+
end
66+
end
67+
end

lib/active_record/tenanted/database_configurations/base_config.rb

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,7 @@ def database_path_for(tenant_name)
3636
end
3737

3838
def tenants
39-
glob = database_path_for("*")
40-
scanner = Regexp.new(database_path_for("(.+)"))
41-
42-
Dir.glob(glob).map do |path|
43-
result = path.scan(scanner).flatten.first
44-
if result.nil?
45-
warn "WARN: ActiveRecord::Tenanted: Cannot parse tenant name from filename #{path.inspect}. " \
46-
"This is a bug, please report it to https://github.com/basecamp/activerecord-tenanted/issues"
47-
end
48-
result
49-
end
39+
ActiveRecord::Tenanted::DatabaseAdapter.tenant_databases(self)
5040
end
5141

5242
def new_tenant_config(tenant_name)
@@ -85,9 +75,7 @@ def coerce_path(path)
8575
end
8676

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

9381
def test_worker_path(path)

lib/active_record/tenanted/database_tasks.rb

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,9 @@ def drop_all
2727
raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
2828

2929
root_config.tenants.each do |tenant|
30-
# NOTE: This is obviously a sqlite-specific implementation.
31-
# TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
32-
# Then this would delegate to the adapter and become adapter-agnostic.
33-
root_config.database_path_for(tenant).tap do |path|
34-
FileUtils.rm(path)
35-
$stdout.puts "Dropped database '#{path}'" if verbose?
36-
end
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?
3733
end
3834
end
3935

lib/active_record/tenanted/tenant.rb

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

103103
def tenant_exist?(tenant_name)
104-
# this will have to be an adapter-specific implementation if we support other than sqlite
105-
database_path = tenanted_root_config.database_path_for(tenant_name)
106-
107-
File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
104+
db_config = tenanted_root_config.new_tenant_config(tenant_name)
105+
ActiveRecord::Tenanted::DatabaseAdapter.database_ready?(db_config)
108106
end
109107

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

124122
def create_tenant(tenant_name, if_not_exists: false, &block)
125123
created_db = false
126-
database_path = tenanted_root_config.database_path_for(tenant_name)
124+
db_config = tenanted_root_config.new_tenant_config(tenant_name)
125+
126+
ActiveRecord::Tenanted::DatabaseAdapter.acquire_ready_lock(db_config) do
127+
unless ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(db_config)
127128

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

135131
with_tenant(tenant_name) do
136132
connection_pool(schema_version_check: false)
@@ -140,7 +136,7 @@ def create_tenant(tenant_name, if_not_exists: false, &block)
140136
created_db = true
141137
end
142138
rescue
143-
FileUtils.rm_f(database_path)
139+
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(db_config)
144140
raise
145141
end
146142

@@ -160,10 +156,8 @@ def destroy_tenant(tenant_name)
160156
end
161157
end
162158

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

169163
def tenants
@@ -213,12 +207,12 @@ def _create_tenanted_pool(schema_version_check: true) # :nodoc:
213207
return superclass._create_tenanted_pool unless connection_class?
214208

215209
tenant = current_tenant
216-
unless File.exist?(tenanted_root_config.database_path_for(tenant))
217-
raise TenantDoesNotExistError, "The database file for tenant #{tenant.inspect} does not exist."
218-
end
210+
db_config = tenanted_root_config.new_tenant_config(tenant)
219211

220-
config = tenanted_root_config.new_tenant_config(tenant)
221-
pool = establish_connection(config)
212+
unless ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(db_config)
213+
raise TenantDoesNotExistError, "The database for tenant #{tenant.inspect} does not exist."
214+
end
215+
pool = establish_connection(db_config)
222216

223217
if schema_version_check
224218
pending_migrations = pool.migration_context.open.pending_migrations

test/unit/database_adapter_test.rb

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
5+
describe ActiveRecord::Tenanted::DatabaseAdapter do
6+
describe ".adapter_for" do
7+
test "selects correct adapter for sqlite3" do
8+
adapter = ActiveRecord::Tenanted::DatabaseAdapter.adapter_for(create_config("sqlite3"))
9+
assert_instance_of ActiveRecord::Tenanted::DatabaseAdapters::SQLite, adapter
10+
end
11+
12+
test "raises error for unsupported adapter" do
13+
unsupported_config = create_config("mongodb")
14+
15+
error = assert_raises ActiveRecord::Tenanted::UnsupportedDatabaseError do
16+
ActiveRecord::Tenanted::DatabaseAdapter.adapter_for(unsupported_config)
17+
end
18+
19+
assert_includes error.message, "Unsupported database adapter for tenanting: mongodb."
20+
end
21+
end
22+
23+
describe "delegation" do
24+
ActiveRecord::Tenanted::DatabaseAdapter::ADAPTERS.each do |adapter, adapter_class_name|
25+
test "#{adapter} .create_database calls adapter's #create_database" do
26+
adapter_mock = Minitest::Mock.new
27+
adapter_mock.expect(:create_database, nil)
28+
29+
adapter_class_name.constantize.stub(:new, adapter_mock) do
30+
ActiveRecord::Tenanted::DatabaseAdapter.create_database(create_config(adapter))
31+
end
32+
33+
assert_mock adapter_mock
34+
end
35+
36+
test "#{adapter} .drop_database calls adapter's #drop_database" do
37+
adapter_mock = Minitest::Mock.new
38+
adapter_mock.expect(:drop_database, nil)
39+
40+
adapter_class_name.constantize.stub(:new, adapter_mock) do
41+
ActiveRecord::Tenanted::DatabaseAdapter.drop_database(create_config(adapter))
42+
end
43+
44+
assert_mock adapter_mock
45+
end
46+
47+
test "#{adapter} .database_exist? calls adapter's #database_exist?" do
48+
adapter_mock = Minitest::Mock.new
49+
adapter_mock.expect(:database_exist?, true)
50+
51+
result = adapter_class_name.constantize.stub(:new, adapter_mock) do
52+
ActiveRecord::Tenanted::DatabaseAdapter.database_exist?(create_config(adapter))
53+
end
54+
55+
assert_equal true, result
56+
assert_mock adapter_mock
57+
end
58+
59+
test "#{adapter} .database_ready? calls adapter's #database_ready?" do
60+
adapter_mock = Minitest::Mock.new
61+
adapter_mock.expect(:database_ready?, true)
62+
63+
result = adapter_class_name.constantize.stub(:new, adapter_mock) do
64+
ActiveRecord::Tenanted::DatabaseAdapter.database_ready?(create_config(adapter))
65+
end
66+
67+
assert_equal true, result
68+
assert_mock adapter_mock
69+
end
70+
71+
test "#{adapter} .tenant_databases calls adapter's #tenant_databases" do
72+
adapter_mock = Minitest::Mock.new
73+
adapter_mock.expect(:tenant_databases, [ "foo", "bar" ])
74+
75+
result = adapter_class_name.constantize.stub(:new, adapter_mock) do
76+
ActiveRecord::Tenanted::DatabaseAdapter.tenant_databases(create_config(adapter))
77+
end
78+
79+
assert_equal [ "foo", "bar" ], result
80+
assert_mock adapter_mock
81+
end
82+
83+
test "#{adapter} .validate_tenant_name calls adapter's #validate_tenant_name" do
84+
adapter_mock = Minitest::Mock.new
85+
adapter_mock.expect(:validate_tenant_name, nil, [ "tenant1" ])
86+
87+
adapter_class_name.constantize.stub(:new, adapter_mock) do
88+
ActiveRecord::Tenanted::DatabaseAdapter.validate_tenant_name(create_config(adapter), "tenant1")
89+
end
90+
91+
assert_mock adapter_mock
92+
end
93+
94+
test "#{adapter} .acquire_ready_lock calls adapter's #acquire_ready_lock" do
95+
fake_adapter = Object.new
96+
fake_adapter.define_singleton_method(:acquire_ready_lock) do |id, &blk|
97+
blk&.call
98+
end
99+
100+
yielded = false
101+
result = adapter_class_name.constantize.stub(:new, fake_adapter) do
102+
ActiveRecord::Tenanted::DatabaseAdapter.acquire_ready_lock(create_config(adapter)) { yielded = true; :ok }
103+
end
104+
105+
assert_equal true, yielded
106+
assert_equal :ok, result
107+
end
108+
end
109+
end
110+
111+
private
112+
def create_config(adapter)
113+
ActiveRecord::DatabaseConfigurations::HashConfig.new(
114+
"test",
115+
"test_config",
116+
{
117+
adapter: adapter,
118+
database: "db_name",
119+
}
120+
)
121+
end
122+
end

test/unit/database_tasks_test.rb

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@
1414
describe ".migrate_tenant" do
1515
for_each_scenario do
1616
setup do
17-
# TODO: This should really be a create_database method on the sqlite3 adapter, see the notes
18-
# in Tenant.create_tenant.
19-
FileUtils.mkdir_p(File.dirname(tenanted_config.database_path_for("foo")))
20-
FileUtils.touch(tenanted_config.database_path_for("foo"))
17+
ActiveRecord::Tenanted::DatabaseAdapter.create_database(tenanted_config.new_tenant_config("foo"))
2118
end
2219

2320
test "database should be created" do

0 commit comments

Comments
 (0)