ros-apartment 3.2.0 → 3.3.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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +93 -2
  4. data/.ruby-version +1 -1
  5. data/Appraisals +15 -0
  6. data/CLAUDE.md +210 -0
  7. data/README.md +1 -1
  8. data/Rakefile +6 -5
  9. data/docs/adapters.md +177 -0
  10. data/docs/architecture.md +274 -0
  11. data/docs/elevators.md +226 -0
  12. data/docs/images/log_example.png +0 -0
  13. data/lib/apartment/CLAUDE.md +300 -0
  14. data/lib/apartment/adapters/CLAUDE.md +314 -0
  15. data/lib/apartment/adapters/abstract_adapter.rb +24 -15
  16. data/lib/apartment/adapters/jdbc_mysql_adapter.rb +1 -1
  17. data/lib/apartment/adapters/jdbc_postgresql_adapter.rb +3 -3
  18. data/lib/apartment/adapters/mysql2_adapter.rb +2 -2
  19. data/lib/apartment/adapters/postgresql_adapter.rb +42 -19
  20. data/lib/apartment/adapters/sqlite3_adapter.rb +7 -7
  21. data/lib/apartment/console.rb +1 -1
  22. data/lib/apartment/custom_console.rb +7 -7
  23. data/lib/apartment/elevators/CLAUDE.md +292 -0
  24. data/lib/apartment/elevators/domain.rb +1 -1
  25. data/lib/apartment/elevators/generic.rb +1 -1
  26. data/lib/apartment/elevators/host_hash.rb +3 -3
  27. data/lib/apartment/elevators/subdomain.rb +9 -5
  28. data/lib/apartment/log_subscriber.rb +1 -1
  29. data/lib/apartment/migrator.rb +2 -2
  30. data/lib/apartment/model.rb +1 -1
  31. data/lib/apartment/railtie.rb +3 -3
  32. data/lib/apartment/tasks/enhancements.rb +1 -1
  33. data/lib/apartment/tasks/task_helper.rb +4 -4
  34. data/lib/apartment/tenant.rb +3 -3
  35. data/lib/apartment/version.rb +1 -1
  36. data/lib/apartment.rb +15 -9
  37. data/lib/generators/apartment/install/install_generator.rb +1 -1
  38. data/lib/generators/apartment/install/templates/apartment.rb +2 -2
  39. data/lib/tasks/apartment.rake +25 -25
  40. data/ros-apartment.gemspec +3 -3
  41. metadata +22 -11
@@ -5,6 +5,7 @@ module Apartment
5
5
  # Abstract adapter from which all the Apartment DB related adapters will inherit the base logic
6
6
  class AbstractAdapter
7
7
  include ActiveSupport::Callbacks
8
+
8
9
  define_callbacks :create, :switch
9
10
 
10
11
  attr_writer :default_tenant
@@ -21,7 +22,7 @@ module Apartment
21
22
  # @param {String} tenant Tenant name
22
23
  #
23
24
  def create(tenant)
24
- run_callbacks :create do
25
+ run_callbacks(:create) do
25
26
  create_tenant(tenant)
26
27
 
27
28
  switch(tenant) do
@@ -72,7 +73,7 @@ module Apartment
72
73
  # @param {String} tenant name
73
74
  #
74
75
  def switch!(tenant = nil)
75
- run_callbacks :switch do
76
+ run_callbacks(:switch) do
76
77
  connect_to_new(tenant).tap do
77
78
  Apartment.connection.clear_query_cache
78
79
  end
@@ -88,9 +89,11 @@ module Apartment
88
89
  switch!(tenant)
89
90
  yield
90
91
  ensure
92
+ # Always attempt rollback to previous tenant, even if block raised
91
93
  begin
92
94
  switch!(previous_tenant)
93
95
  rescue StandardError => _e
96
+ # If rollback fails (tenant was dropped, connection lost), fall back to default
94
97
  reset
95
98
  end
96
99
  end
@@ -99,7 +102,7 @@ module Apartment
99
102
  #
100
103
  def each(tenants = Apartment.tenant_names)
101
104
  tenants.each do |tenant|
102
- switch(tenant) { yield tenant }
105
+ switch(tenant) { yield(tenant) }
103
106
  end
104
107
  end
105
108
 
@@ -116,7 +119,7 @@ module Apartment
116
119
  # Reset the tenant connection to the default
117
120
  #
118
121
  def reset
119
- Apartment.establish_connection @config
122
+ Apartment.establish_connection(@config)
120
123
  end
121
124
 
122
125
  # Load the rails seed file into the db
@@ -147,7 +150,7 @@ module Apartment
147
150
  protected
148
151
 
149
152
  def process_excluded_model(excluded_model)
150
- excluded_model.constantize.establish_connection @config
153
+ excluded_model.constantize.establish_connection(@config)
151
154
  end
152
155
 
153
156
  def drop_command(conn, tenant)
@@ -178,11 +181,14 @@ module Apartment
178
181
  def connect_to_new(tenant)
179
182
  return reset if tenant.nil?
180
183
 
184
+ # Preserve query cache state across tenant switches
185
+ # Rails disables it during connection establishment
181
186
  query_cache_enabled = ActiveRecord::Base.connection.query_cache_enabled
182
187
 
183
- Apartment.establish_connection multi_tenantify(tenant)
184
- Apartment.connection.verify! # call active? to manually check if this connection is valid
188
+ Apartment.establish_connection(multi_tenantify(tenant))
189
+ Apartment.connection.verify! # Explicitly validate connection is live
185
190
 
191
+ # Restore query cache if it was previously enabled
186
192
  Apartment.connection.enable_query_cache! if query_cache_enabled
187
193
  rescue *rescuable_exceptions => e
188
194
  Apartment::Tenant.reset if reset_on_connection_exception?
@@ -216,7 +222,7 @@ module Apartment
216
222
  # Load a file or raise error if it doesn't exists
217
223
  #
218
224
  def load_or_raise(file)
219
- raise FileNotFound, "#{file} doesn't exist yet" unless File.exist?(file)
225
+ raise(FileNotFound, "#{file} doesn't exist yet") unless File.exist?(file)
220
226
 
221
227
  load(file)
222
228
  end
@@ -239,15 +245,16 @@ module Apartment
239
245
  Apartment.db_config_for(tenant).dup
240
246
  end
241
247
 
242
- def with_neutral_connection(tenant, &_block)
248
+ def with_neutral_connection(tenant, &)
243
249
  if Apartment.with_multi_server_setup
244
- # neutral connection is necessary whenever you need to create/remove a database from a server.
245
- # example: when you use postgresql, you need to connect to the default postgresql database before you create
246
- # your own.
250
+ # Multi-server setup requires separate connection handler to avoid polluting
251
+ # the main connection pool. For example: connecting to postgres 'template1'
252
+ # database to CREATE/DROP tenant databases without affecting app connections.
247
253
  SeparateDbConnectionHandler.establish_connection(multi_tenantify(tenant, false))
248
254
  yield(SeparateDbConnectionHandler.connection)
249
255
  SeparateDbConnectionHandler.connection.close
250
256
  else
257
+ # Single-server: reuse existing connection (safe for most operations)
251
258
  yield(Apartment.connection)
252
259
  end
253
260
  end
@@ -257,17 +264,19 @@ module Apartment
257
264
  end
258
265
 
259
266
  def raise_drop_tenant_error!(tenant, exception)
260
- raise TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}"
267
+ raise(TenantNotFound, "Error while dropping tenant #{environmentify(tenant)}: #{exception.message}")
261
268
  end
262
269
 
263
270
  def raise_create_tenant_error!(tenant, exception)
264
- raise TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}"
271
+ raise(TenantExists, "Error while creating tenant #{environmentify(tenant)}: #{exception.message}")
265
272
  end
266
273
 
267
274
  def raise_connect_error!(tenant, exception)
268
- raise TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}"
275
+ raise(TenantNotFound, "Error while connecting to tenant #{environmentify(tenant)}: #{exception.message}")
269
276
  end
270
277
 
278
+ # Dedicated AR connection class for neutral connections (admin operations like CREATE/DROP DATABASE).
279
+ # Prevents admin commands from polluting the main application connection pool.
271
280
  class SeparateDbConnectionHandler < ::ActiveRecord::Base
272
281
  end
273
282
  end
@@ -5,7 +5,7 @@ require 'apartment/adapters/abstract_jdbc_adapter'
5
5
  module Apartment
6
6
  module Tenant
7
7
  def self.jdbc_mysql_adapter(config)
8
- Adapters::JDBCMysqlAdapter.new config
8
+ Adapters::JDBCMysqlAdapter.new(config)
9
9
  end
10
10
  end
11
11
 
@@ -38,12 +38,12 @@ module Apartment
38
38
  #
39
39
  def connect_to_new(tenant = nil)
40
40
  return reset if tenant.nil?
41
- raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant)
41
+ raise(ActiveRecord::StatementInvalid, "Could not find schema #{tenant}") unless schema_exists?(tenant)
42
42
 
43
43
  @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s
44
44
  Apartment.connection.schema_search_path = full_search_path
45
45
  rescue ActiveRecord::StatementInvalid, ActiveRecord::JDBCError
46
- raise TenantNotFound, "One of the following schema(s) is invalid: #{full_search_path}"
46
+ raise(TenantNotFound, "One of the following schema(s) is invalid: #{full_search_path}")
47
47
  end
48
48
 
49
49
  private
@@ -51,7 +51,7 @@ module Apartment
51
51
  def tenant_exists?(tenant)
52
52
  return true unless Apartment.tenant_presence_check
53
53
 
54
- Apartment.connection.all_schemas.include? tenant
54
+ Apartment.connection.all_schemas.include?(tenant)
55
55
  end
56
56
 
57
57
  def rescue_from
@@ -44,7 +44,7 @@ module Apartment
44
44
  def reset
45
45
  return unless default_tenant
46
46
 
47
- Apartment.connection.execute "use `#{default_tenant}`"
47
+ Apartment.connection.execute("use `#{default_tenant}`")
48
48
  end
49
49
 
50
50
  protected
@@ -54,7 +54,7 @@ module Apartment
54
54
  def connect_to_new(tenant)
55
55
  return reset if tenant.nil?
56
56
 
57
- Apartment.connection.execute "use `#{environmentify(tenant)}`"
57
+ Apartment.connection.execute("use `#{environmentify(tenant)}`")
58
58
  rescue ActiveRecord::StatementInvalid => e
59
59
  Apartment::Tenant.reset
60
60
  raise_connect_error!(tenant, e)
@@ -57,7 +57,8 @@ module Apartment
57
57
 
58
58
  def process_excluded_model(excluded_model)
59
59
  excluded_model.constantize.tap do |klass|
60
- # Ensure that if a schema *was* set, we override
60
+ # Strip any existing schema qualifier (handles "schema.table" → "table")
61
+ # Then explicitly set to default schema to prevent tenant-based queries
61
62
  table_name = klass.table_name.split('.', 2).last
62
63
 
63
64
  klass.table_name = "#{default_tenant}.#{table_name}"
@@ -72,7 +73,7 @@ module Apartment
72
73
  #
73
74
  def connect_to_new(tenant = nil)
74
75
  return reset if tenant.nil?
75
- raise ActiveRecord::StatementInvalid, "Could not find schema #{tenant}" unless schema_exists?(tenant)
76
+ raise(ActiveRecord::StatementInvalid, "Could not find schema #{tenant}") unless schema_exists?(tenant)
76
77
 
77
78
  @current = tenant.is_a?(Array) ? tenant.map(&:to_s) : tenant.to_s
78
79
  Apartment.connection.schema_search_path = full_search_path
@@ -89,7 +90,8 @@ module Apartment
89
90
  end
90
91
 
91
92
  def create_tenant_command(conn, tenant)
92
- # NOTE: This was causing some tests to fail because of the database strategy for rspec
93
+ # Avoid nested transactions: if already in transaction (e.g., RSpec tests),
94
+ # execute directly. Otherwise, wrap in explicit transaction for atomicity.
93
95
  if ActiveRecord::Base.connection.open_transactions.positive?
94
96
  conn.execute(%(CREATE SCHEMA "#{tenant}"))
95
97
  else
@@ -101,7 +103,7 @@ module Apartment
101
103
  end
102
104
  rescue *rescuable_exceptions => e
103
105
  rollback_transaction(conn)
104
- raise e
106
+ raise(e)
105
107
  end
106
108
 
107
109
  def rollback_transaction(conn)
@@ -131,7 +133,7 @@ module Apartment
131
133
  end
132
134
 
133
135
  def raise_schema_connect_to_new(tenant, exception)
134
- raise TenantNotFound, <<~EXCEPTION_MESSAGE
136
+ raise(TenantNotFound, <<~EXCEPTION_MESSAGE)
135
137
  Could not set search path to schemas, they may be invalid: "#{tenant}" #{full_search_path}.
136
138
  Original error: #{exception.class}: #{exception}
137
139
  EXCEPTION_MESSAGE
@@ -152,6 +154,26 @@ module Apartment
152
154
 
153
155
  ].freeze
154
156
 
157
+ # PostgreSQL meta-commands (backslash commands) that appear in pg_dump output
158
+ # but are not valid SQL when passed to ActiveRecord's execute().
159
+ # These must be filtered out to prevent syntax errors during schema import.
160
+ PSQL_META_COMMANDS = [
161
+ /^\\connect/i,
162
+ /^\\set/i,
163
+ /^\\unset/i,
164
+ /^\\copyright/i,
165
+ /^\\echo/i,
166
+ /^\\warn/i,
167
+ /^\\o/i,
168
+ /^\\t/i,
169
+ /^\\q/i,
170
+ /^\\./i, # Catch-all for any backslash command (e.g., \. for COPY delimiter,
171
+ # \restrict/\unrestrict in PostgreSQL 17.6+, and future meta-commands)
172
+ ].freeze
173
+
174
+ # Combined blacklist: SQL statements and psql meta-commands to filter from pg_dump output
175
+ PSQL_DUMP_GLOBAL_BLACKLIST = (PSQL_DUMP_BLACKLISTED_STATEMENTS + PSQL_META_COMMANDS).freeze
176
+
155
177
  def import_database_schema
156
178
  preserving_search_path do
157
179
  clone_pg_schema
@@ -161,9 +183,9 @@ module Apartment
161
183
 
162
184
  private
163
185
 
164
- # Re-set search path after the schema is imported.
165
- # Postgres now sets search path to empty before dumping the schema
166
- # and it mut be reset
186
+ # PostgreSQL's pg_dump clears search_path in the dump output, which would
187
+ # leave us with an empty path after import. Capture current path, execute
188
+ # import, then restore it to maintain tenant context.
167
189
  #
168
190
  def preserving_search_path
169
191
  search_path = Apartment.connection.execute('show search_path').first['search_path']
@@ -209,13 +231,14 @@ module Apartment
209
231
  end
210
232
  # rubocop:enable Layout/LineLength
211
233
 
212
- # Temporary set Postgresql related environment variables if there are in @config
213
- #
234
+ # Temporarily set PostgreSQL environment variables for pg_dump shell commands.
235
+ # Must preserve and restore existing ENV values to avoid polluting global state.
236
+ # pg_dump reads these instead of passing connection params as CLI args.
214
237
  def with_pg_env
215
- pghost = ENV['PGHOST']
216
- pgport = ENV['PGPORT']
217
- pguser = ENV['PGUSER']
218
- pgpassword = ENV['PGPASSWORD']
238
+ pghost = ENV.fetch('PGHOST', nil)
239
+ pgport = ENV.fetch('PGPORT', nil)
240
+ pguser = ENV.fetch('PGUSER', nil)
241
+ pgpassword = ENV.fetch('PGPASSWORD', nil)
219
242
 
220
243
  ENV['PGHOST'] = @config[:host] if @config[:host]
221
244
  ENV['PGPORT'] = @config[:port].to_s if @config[:port]
@@ -224,6 +247,7 @@ module Apartment
224
247
 
225
248
  yield
226
249
  ensure
250
+ # Always restore original ENV state (might be nil)
227
251
  ENV['PGHOST'] = pghost
228
252
  ENV['PGPORT'] = pgport
229
253
  ENV['PGUSER'] = pguser
@@ -239,16 +263,15 @@ module Apartment
239
263
 
240
264
  swap_schema_qualifier(sql)
241
265
  .split("\n")
242
- .select { |line| check_input_against_regexps(line, PSQL_DUMP_BLACKLISTED_STATEMENTS).empty? }
266
+ .grep_v(Regexp.union(PSQL_DUMP_GLOBAL_BLACKLIST))
243
267
  .prepend(search_path)
244
268
  .join("\n")
245
269
  end
246
270
 
247
271
  def swap_schema_qualifier(sql)
248
272
  sql.gsub(/#{default_tenant}\.\w*/) do |match|
249
- if Apartment.pg_excluded_names.any? { |name| match.include? name }
250
- match
251
- elsif Apartment.pg_exclude_clone_tables && excluded_tables.any?(match)
273
+ if Apartment.pg_excluded_names.any? { |name| match.include?(name) } ||
274
+ (Apartment.pg_exclude_clone_tables && excluded_tables.any?(match))
252
275
  match
253
276
  else
254
277
  match.gsub("#{default_tenant}.", %("#{current}".))
@@ -259,7 +282,7 @@ module Apartment
259
282
  # Checks if any of regexps matches against input
260
283
  #
261
284
  def check_input_against_regexps(input, regexps)
262
- regexps.select { |c| input.match c }
285
+ regexps.select { |c| input.match(c) }
263
286
  end
264
287
 
265
288
  # Convenience method for excluded table names
@@ -19,8 +19,8 @@ module Apartment
19
19
 
20
20
  def drop(tenant)
21
21
  unless File.exist?(database_file(tenant))
22
- raise TenantNotFound,
23
- "The tenant #{environmentify(tenant)} cannot be found."
22
+ raise(TenantNotFound,
23
+ "The tenant #{environmentify(tenant)} cannot be found.")
24
24
  end
25
25
 
26
26
  File.delete(database_file(tenant))
@@ -36,17 +36,17 @@ module Apartment
36
36
  return reset if tenant.nil?
37
37
 
38
38
  unless File.exist?(database_file(tenant))
39
- raise TenantNotFound,
40
- "The tenant #{environmentify(tenant)} cannot be found."
39
+ raise(TenantNotFound,
40
+ "The tenant #{environmentify(tenant)} cannot be found.")
41
41
  end
42
42
 
43
- super database_file(tenant)
43
+ super(database_file(tenant))
44
44
  end
45
45
 
46
46
  def create_tenant(tenant)
47
47
  if File.exist?(database_file(tenant))
48
- raise TenantExists,
49
- "The tenant #{environmentify(tenant)} already exists."
48
+ raise(TenantExists,
49
+ "The tenant #{environmentify(tenant)} already exists.")
50
50
  end
51
51
 
52
52
  begin
@@ -4,7 +4,7 @@ def st(schema_name = nil)
4
4
  if schema_name.nil?
5
5
  tenant_list.each { |t| puts t }
6
6
 
7
- elsif tenant_list.include? schema_name
7
+ elsif tenant_list.include?(schema_name)
8
8
  Apartment::Tenant.switch!(schema_name)
9
9
  else
10
10
  puts "Tenant #{schema_name} is not part of the tenant list"
@@ -5,7 +5,7 @@ require_relative 'console'
5
5
  module Apartment
6
6
  module CustomConsole
7
7
  begin
8
- require 'pry-rails'
8
+ require('pry-rails')
9
9
  rescue LoadError
10
10
  # rubocop:disable Layout/LineLength
11
11
  puts '[Failed to load pry-rails] If you want to use Apartment custom prompt you need to add pry-rails to your gemfile'
@@ -13,17 +13,17 @@ module Apartment
13
13
  end
14
14
 
15
15
  desc = "Includes the current Rails environment and project folder name.\n" \
16
- '[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>'
16
+ '[1] [project_name][Rails.env][Apartment::Tenant.current] pry(main)>'
17
17
 
18
18
  prompt_procs = [
19
19
  proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '>') },
20
- proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') }
20
+ proc { |target_self, nest_level, pry| prompt_contents(pry, target_self, nest_level, '*') },
21
21
  ]
22
22
 
23
23
  if Gem::Version.new(Pry::VERSION) >= Gem::Version.new('0.13')
24
- Pry.config.prompt = Pry::Prompt.new 'ros', desc, prompt_procs
24
+ Pry.config.prompt = Pry::Prompt.new('ros', desc, prompt_procs)
25
25
  else
26
- Pry::Prompt.add 'ros', desc, %w[> *] do |target_self, nest_level, pry, sep|
26
+ Pry::Prompt.add('ros', desc, %w[> *]) do |target_self, nest_level, pry, sep|
27
27
  prompt_contents(pry, target_self, nest_level, sep)
28
28
  end
29
29
  Pry.config.prompt = Pry::Prompt[:ros][:value]
@@ -35,8 +35,8 @@ module Apartment
35
35
 
36
36
  def self.prompt_contents(pry, target_self, nest_level, sep)
37
37
  "[#{pry.input_ring.size}] [#{PryRails::Prompt.formatted_env}][#{Apartment::Tenant.current}] " \
38
- "#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \
39
- "#{":#{nest_level}" unless nest_level.zero?}#{sep} "
38
+ "#{pry.config.prompt_name}(#{Pry.view_clip(target_self)})" \
39
+ "#{":#{nest_level}" unless nest_level.zero?}#{sep} "
40
40
  end
41
41
  end
42
42
  end