activerecord-tenanted 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2dbae49297c735850801a4994217ba8ce3ff794345159748c881c6e5e33a6b86
4
- data.tar.gz: 42e0b45e14927d7c403db84baf20499c9297572993d30526522e0ecb810ad87f
3
+ metadata.gz: e6045a554346528724708d7acfcefa45c3445f5dd1bbc184426ce13e995bbb83
4
+ data.tar.gz: 5c752644fcb587efff882e8c117933147dccc5771e74c3ea3d9b967eaa33f5dc
5
5
  SHA512:
6
- metadata.gz: d897569771373793bb56468562b9070d523d1cebe1a2fa1acd7da8e7db40d92f59c8e51439161f33d5c9e3456c9d5797499b2596fc5dcc515b5a4b93959f8122
7
- data.tar.gz: f4edce93a3be90e901251be1b00f788753056c94d4fe0c1df71fcccd0b28aba52f9116c2f144ac94a1b72acb194d44a1e7736df0d971c520088c4a2fd5cddb85
6
+ metadata.gz: cb160898373d824906708a959d59b40d1f76fa398b2fcdebadd57b590ddd63a3da2fcbe53eb8787a5af041f108eb27516afb27febe4aa62eb76c9a1cd7cccd4f
7
+ data.tar.gz: e001ccf67a7e4891df031ee6ceb34c20f227cb3ef3499decf2e5cce59b638a76f9a8b4200c55105910a74c3f7478c11b32aeb34d8f7da481d8b388a51997b899
@@ -0,0 +1,25 @@
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 new(db_config)
12
+ adapter_class_name = ADAPTERS[db_config.adapter]
13
+
14
+ if adapter_class_name.nil?
15
+ raise ActiveRecord::Tenanted::UnsupportedDatabaseError,
16
+ "Unsupported database adapter for tenanting: #{db_config.adapter}. " \
17
+ "Supported adapters: #{ADAPTERS.keys.join(', ')}"
18
+ end
19
+
20
+ adapter_class_name.constantize.new(db_config)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Tenanted
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
+
20
+ def initialize(db_config)
21
+ @db_config = db_config
22
+ end
23
+
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
42
+
43
+ def create_database
44
+ ensure_database_directory_exists
45
+ FileUtils.touch(database_path)
46
+ end
47
+
48
+ def drop_database
49
+ # Remove the SQLite database file and associated files
50
+ FileUtils.rm_f(database_path)
51
+ FileUtils.rm_f("#{database_path}-wal") # Write-Ahead Logging file
52
+ FileUtils.rm_f("#{database_path}-shm") # Shared Memory file
53
+ end
54
+
55
+ def database_exist?
56
+ File.exist?(database_path)
57
+ end
58
+
59
+ def database_ready?
60
+ File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
61
+ end
62
+
63
+ def acquire_ready_lock(&block)
64
+ ActiveRecord::Tenanted::Mutex::Ready.lock(database_path, &block)
65
+ end
66
+
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)
73
+ end
74
+ end
75
+
76
+ def database_path
77
+ path_for(db_config.database)
78
+ end
79
+
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
+ # This check is needed because of https://github.com/rails/rails/pull/55769 adding
87
+ # replicas to the parallelization setup by using `include_hidden: true` which pulls in
88
+ # the BaseConfig. We don't want to double-suffix the database name.
89
+ #
90
+ # TODO: Ideally we should have finer-grained filtering of database configurations in Rails
91
+ # (other than simply hidden or not-hidden).
92
+ if db.end_with?(test_worker_suffix)
93
+ db
94
+ else
95
+ db + test_worker_suffix
96
+ end
97
+ end
98
+ end
99
+
100
+ # A sqlite database path can be a file path or a URI (either relative or absolute). We
101
+ # can't parse it as a standard URI in all circumstances, though, see
102
+ # https://sqlite.org/uri.html
103
+ def path_for(database)
104
+ if database.start_with?("file:/")
105
+ URI.parse(database).path
106
+ elsif database.start_with?("file:")
107
+ URI.parse(database.sub(/\?.*$/, "")).opaque
108
+ else
109
+ database
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -11,6 +11,11 @@ module ActiveRecord
11
11
  def initialize(...)
12
12
  super
13
13
  @test_worker_id = nil
14
+ @config_adapter = nil
15
+ end
16
+
17
+ def config_adapter
18
+ @config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self)
14
19
  end
15
20
 
16
21
  def database_tasks?
@@ -20,33 +25,19 @@ module ActiveRecord
20
25
  def database_for(tenant_name)
21
26
  tenant_name = tenant_name.to_s
22
27
 
23
- validate_tenant_name(tenant_name)
28
+ config_adapter.validate_tenant_name(tenant_name)
24
29
 
25
- path = sprintf(database, tenant: tenant_name)
30
+ db = sprintf(database, tenant: tenant_name)
26
31
 
27
32
  if test_worker_id
28
- test_worker_path(path)
29
- else
30
- path
33
+ db = config_adapter.test_workerize(db, test_worker_id)
31
34
  end
32
- end
33
35
 
34
- def database_path_for(tenant_name)
35
- coerce_path(database_for(tenant_name))
36
+ db
36
37
  end
37
38
 
38
39
  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
40
+ config_adapter.tenant_databases
50
41
  end
51
42
 
52
43
  def new_tenant_config(tenant_name)
@@ -54,7 +45,6 @@ module ActiveRecord
54
45
  config_hash = configuration_hash.dup.tap do |hash|
55
46
  hash[:tenant] = tenant_name
56
47
  hash[:database] = database_for(tenant_name)
57
- hash[:database_path] = database_path_for(tenant_name)
58
48
  hash[:tenanted_config_name] = name
59
49
  end
60
50
  Tenanted::DatabaseConfigurations::TenantConfig.new(env_name, config_name, config_hash)
@@ -70,35 +60,6 @@ module ActiveRecord
70
60
  def max_connection_pools
71
61
  (configuration_hash[:max_connection_pools] || DEFAULT_MAX_CONNECTION_POOLS).to_i
72
62
  end
73
-
74
- private
75
- # A sqlite database path can be a file path or a URI (either relative or absolute).
76
- # We can't parse it as a standard URI in all circumstances, though, see https://sqlite.org/uri.html
77
- def coerce_path(path)
78
- if path.start_with?("file:/")
79
- URI.parse(path).path
80
- elsif path.start_with?("file:")
81
- URI.parse(path.sub(/\?.*$/, "")).opaque
82
- else
83
- path
84
- end
85
- end
86
-
87
- 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
91
- end
92
-
93
- def test_worker_path(path)
94
- test_worker_suffix = "_#{test_worker_id}"
95
-
96
- if path.start_with?("file:") && path.include?("?")
97
- path.sub(/(\?.*)$/, "#{test_worker_suffix}\\1")
98
- else
99
- path + test_worker_suffix
100
- end
101
- end
102
63
  end
103
64
  end
104
65
  end
@@ -4,12 +4,24 @@ module ActiveRecord
4
4
  module Tenanted
5
5
  module DatabaseConfigurations
6
6
  class TenantConfig < ActiveRecord::DatabaseConfigurations::HashConfig
7
+ def initialize(...)
8
+ super
9
+ @config_adapter = nil
10
+ end
11
+
7
12
  def tenant
8
13
  configuration_hash.fetch(:tenant)
9
14
  end
10
15
 
16
+ def config_adapter
17
+ @config_adapter ||= ActiveRecord::Tenanted::DatabaseAdapter.new(self)
18
+ end
19
+
11
20
  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
+
13
25
  super.tap { |conn| conn.tenant = tenant }
14
26
  end
15
27
 
@@ -36,20 +48,6 @@ module ActiveRecord
36
48
  File.join(db_dir, "#{tenanted_config_name}_schema_cache.yml")
37
49
  end
38
50
  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
53
51
  end
54
52
  end
55
53
  end
@@ -6,38 +6,34 @@ module ActiveRecord
6
6
  extend self
7
7
 
8
8
  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
10
10
 
11
- tenants = root_config.tenants.presence || [ get_current_tenant ].compact
11
+ tenants = config.tenants.presence || [ get_current_tenant ].compact
12
12
  tenants.each do |tenant|
13
- tenant_config = root_config.new_tenant_config(tenant)
13
+ tenant_config = config.new_tenant_config(tenant)
14
14
  migrate(tenant_config)
15
15
  end
16
16
  end
17
17
 
18
18
  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
20
20
 
21
- tenant_config = root_config.new_tenant_config(tenant_name)
21
+ tenant_config = config.new_tenant_config(tenant_name)
22
22
 
23
23
  migrate(tenant_config)
24
24
  end
25
25
 
26
26
  def drop_all
27
- raise ArgumentError, "Could not find a tenanted database" unless root_config = root_database_config
28
-
29
- 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
27
+ raise ArgumentError, "Could not find a tenanted database" unless config = base_config
28
+
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?
37
33
  end
38
34
  end
39
35
 
40
- def root_database_config
36
+ def base_config
41
37
  db_configs = ActiveRecord::Base.configurations.configs_for(
42
38
  env_name: ActiveRecord::Tasks::DatabaseTasks.env,
43
39
  include_hidden: true
@@ -13,11 +13,13 @@ module ActiveRecord
13
13
  end
14
14
 
15
15
  def cache_key
16
- tenant ? "#{super}?tenant=#{tenant}" : super
16
+ tenant ? "#{tenant}/#{super}" : super
17
17
  end
18
18
 
19
19
  def inspect
20
- tenant ? super.sub(/>$/, ", tenant=#{tenant.inspect}>") : super
20
+ return super unless tenant
21
+
22
+ super.sub(/\A#<\S+ /, "\\0tenant: #{tenant.inspect}, ")
21
23
  end
22
24
 
23
25
  def to_global_id(options = {})
@@ -99,10 +101,7 @@ module ActiveRecord
99
101
  end
100
102
 
101
103
  def tenant_exist?(tenant_name)
102
- # this will have to be an adapter-specific implementation if we support other than sqlite
103
- database_path = tenanted_root_config.database_path_for(tenant_name)
104
-
105
- File.exist?(database_path) && !ActiveRecord::Tenanted::Mutex::Ready.locked?(database_path)
104
+ tenanted_root_config.new_tenant_config(tenant_name).config_adapter.database_ready?
106
105
  end
107
106
 
108
107
  def with_tenant(tenant_name, prohibit_shard_swapping: true, &block)
@@ -121,14 +120,11 @@ module ActiveRecord
121
120
 
122
121
  def create_tenant(tenant_name, if_not_exists: false, &block)
123
122
  created_db = false
124
- database_path = tenanted_root_config.database_path_for(tenant_name)
123
+ adapter = tenanted_root_config.new_tenant_config(tenant_name).config_adapter
125
124
 
126
- ActiveRecord::Tenanted::Mutex::Ready.lock(database_path) do
127
- unless File.exist?(database_path)
128
- # NOTE: This is obviously a sqlite-specific implementation.
129
- # TODO: Add a `create_database` method upstream in the sqlite3 adapter, and call it.
130
- # Then this would delegate to the adapter and become adapter-agnostic.
131
- FileUtils.touch(database_path)
125
+ adapter.acquire_ready_lock do
126
+ unless adapter.database_exist?
127
+ adapter.create_database
132
128
 
133
129
  with_tenant(tenant_name) do
134
130
  connection_pool(schema_version_check: false)
@@ -138,7 +134,7 @@ module ActiveRecord
138
134
  created_db = true
139
135
  end
140
136
  rescue
141
- FileUtils.rm_f(database_path)
137
+ adapter.drop_database
142
138
  raise
143
139
  end
144
140
 
@@ -158,10 +154,7 @@ module ActiveRecord
158
154
  end
159
155
  end
160
156
 
161
- # NOTE: This is obviously a sqlite-specific implementation.
162
- # TODO: Create a `drop_database` method upstream in the sqlite3 adapter, and call it.
163
- # Then this would delegate to the adapter and become adapter-agnostic.
164
- FileUtils.rm_f(tenanted_root_config.database_path_for(tenant_name))
157
+ tenanted_root_config.new_tenant_config(tenant_name).config_adapter.drop_database
165
158
  end
166
159
 
167
160
  def tenants
@@ -211,12 +204,12 @@ module ActiveRecord
211
204
  return superclass._create_tenanted_pool unless connection_class?
212
205
 
213
206
  tenant = current_tenant
214
- unless File.exist?(tenanted_root_config.database_path_for(tenant))
215
- raise TenantDoesNotExistError, "The database file for tenant #{tenant.inspect} does not exist."
216
- end
207
+ db_config = tenanted_root_config.new_tenant_config(tenant)
217
208
 
218
- config = tenanted_root_config.new_tenant_config(tenant)
219
- pool = establish_connection(config)
209
+ unless db_config.config_adapter.database_exist?
210
+ raise TenantDoesNotExistError, "The database for tenant #{tenant.inspect} does not exist."
211
+ end
212
+ pool = establish_connection(db_config)
220
213
 
221
214
  if schema_version_check
222
215
  pending_migrations = pool.migration_context.open.pending_migrations
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Tenanted
5
- VERSION = "0.4.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -4,6 +4,9 @@ require "active_record"
4
4
 
5
5
  require "zeitwerk"
6
6
  loader = Zeitwerk::Loader.for_gem_extension(ActiveRecord)
7
+ loader.inflector.inflect(
8
+ "sqlite" => "SQLite",
9
+ )
7
10
  loader.setup
8
11
 
9
12
  module ActiveRecord
@@ -35,6 +38,9 @@ module ActiveRecord
35
38
  # Raised when the Rails integration is being invoked but has not been configured.
36
39
  class IntegrationNotConfiguredError < Error; end
37
40
 
41
+ # Raised when an unsupported database adapter is used.
42
+ class UnsupportedDatabaseError < Error; end
43
+
38
44
  def self.connection_class
39
45
  # TODO: cache this / speed this up
40
46
  Rails.application.config.active_record_tenanted.connection_class&.constantize
@@ -8,7 +8,7 @@ namespace :db do
8
8
  next
9
9
  end
10
10
 
11
- unless ActiveRecord::Tenanted::DatabaseTasks.root_database_config
11
+ unless ActiveRecord::Tenanted::DatabaseTasks.base_config
12
12
  warn "WARNING: No tenanted database found, skipping tenanted migration"
13
13
  else
14
14
  begin
@@ -42,7 +42,7 @@ namespace :db do
42
42
 
43
43
  desc "Drop all tenanted databases for the current environment"
44
44
  task "drop:tenant" => "load_config" do
45
- unless ActiveRecord::Tenanted::DatabaseTasks.root_database_config
45
+ unless ActiveRecord::Tenanted::DatabaseTasks.base_config
46
46
  warn "WARNING: No tenanted database found, skipping tenanted reset"
47
47
  else
48
48
  begin
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-tenanted
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Dalessio
@@ -73,6 +73,8 @@ files:
73
73
  - lib/active_record/tenanted/cable_connection.rb
74
74
  - lib/active_record/tenanted/connection_adapter.rb
75
75
  - lib/active_record/tenanted/console.rb
76
+ - lib/active_record/tenanted/database_adapter.rb
77
+ - lib/active_record/tenanted/database_adapters/sqlite.rb
76
78
  - lib/active_record/tenanted/database_configurations.rb
77
79
  - lib/active_record/tenanted/database_configurations/base_config.rb
78
80
  - lib/active_record/tenanted/database_configurations/tenant_config.rb