ros-apartment 4.0.0.alpha1 → 4.0.0.alpha2

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +67 -3
  3. data/config/default.yml +9 -0
  4. data/lib/apartment/CLAUDE.md +23 -4
  5. data/lib/apartment/adapters/abstract_adapter.rb +100 -18
  6. data/lib/apartment/adapters/mysql2_adapter.rb +48 -0
  7. data/lib/apartment/adapters/postgresql_database_adapter.rb +29 -0
  8. data/lib/apartment/adapters/postgresql_schema_adapter.rb +56 -1
  9. data/lib/apartment/adapters/sqlite3_adapter.rb +14 -0
  10. data/lib/apartment/cli/migrations.rb +1 -1
  11. data/lib/apartment/cli/seeds.rb +1 -1
  12. data/lib/apartment/cli/tenants.rb +2 -2
  13. data/lib/apartment/concerns/model.rb +111 -6
  14. data/lib/apartment/config.rb +51 -3
  15. data/lib/apartment/configs/postgresql_config.rb +13 -4
  16. data/lib/apartment/current.rb +8 -1
  17. data/lib/apartment/elevators/CLAUDE.md +10 -7
  18. data/lib/apartment/elevators/generic.rb +77 -6
  19. data/lib/apartment/errors.rb +73 -0
  20. data/lib/apartment/lifecycle.rb +32 -0
  21. data/lib/apartment/migrator.rb +2 -2
  22. data/lib/apartment/patches/connection_handling.rb +10 -5
  23. data/lib/apartment/patches/live_tenant_propagation.rb +53 -0
  24. data/lib/apartment/pool_manager.rb +15 -0
  25. data/lib/apartment/pool_reaper.rb +87 -3
  26. data/lib/apartment/railtie.rb +111 -3
  27. data/lib/apartment/schema_cache.rb +1 -1
  28. data/lib/apartment/tenant.rb +252 -13
  29. data/lib/apartment/tenant_validator.rb +186 -0
  30. data/lib/apartment/test_fixtures.rb +34 -0
  31. data/lib/apartment/version.rb +1 -1
  32. data/lib/apartment.rb +139 -12
  33. data/lib/generators/apartment/install/templates/apartment.rb +10 -2
  34. data/lib/rubocop/apartment.rb +4 -0
  35. data/lib/rubocop/cop/apartment/no_direct_current_write.rb +79 -0
  36. data/lib/rubocop/cop/apartment/prefer_block_switch.rb +39 -0
  37. data/ros-apartment.gemspec +1 -1
  38. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff587e273664aea9b3fba6477c64cdd5309dfa75a7b47715e6671452aa209788
4
- data.tar.gz: 1684f55b0be423185a8f8b6b41270028a1cfb07bd240afee5325ba6f9da4b6ca
3
+ metadata.gz: efc3d3a91e17846826ac5d5ca707cd17bdc5a5923b18fb4c0162ac87f24014d0
4
+ data.tar.gz: 5c1787f4e9cee805842e6035fcc2769415dfccdf90c3c2b354315271315638a2
5
5
  SHA512:
6
- metadata.gz: 0b0a44d91f78033ace32559a0d8e8095ba287dfa98cd07c084a5f3f9b79341a21cd3ba36a439412360f37d4147dc83e8fbba944a92a01ffa8d799d23eb746050
7
- data.tar.gz: efba1ef8faff63c0ce3795e29d1f16fad2eaf894ba62346ee8aa37cf8fb000c86caa59b865567ee4490898bca4ec2cee19aba16fd0643bee9e8b5ee2bfff8409
6
+ metadata.gz: 486bb0cd94225925ada99a497c89e314536300a261b898c3575a5008211e1e8f1632a9755dadae096f5814eaee00920e9652f70f8feb8676ff58897fc4262f2c
7
+ data.tar.gz: e9b88d3c86be4e0891ff584e85c822979c6146ece01ce2bab17f442603e8add4aef3e826157157df3325722e39209a70bf94ffdd218504abdbf6be7dd4af29cf
data/README.md CHANGED
@@ -60,7 +60,7 @@ The generated initializer at `config/initializers/apartment.rb` configures Apart
60
60
  Apartment.configure do |config|
61
61
  config.tenant_strategy = :schema # :schema (PostgreSQL) or :database_name (MySQL/SQLite)
62
62
  config.tenants_provider = -> { Customer.pluck(:subdomain) }
63
- config.default_tenant = 'public'
63
+ config.default_tenant = 'public' # auto-defaults for :schema; required for :database_name
64
64
  end
65
65
  ```
66
66
 
@@ -112,7 +112,7 @@ config.elevator = :subdomain
112
112
  config.elevator_options = {}
113
113
  ```
114
114
 
115
- The Railtie auto-inserts elevator middleware. No manual `config.middleware.use` needed.
115
+ The Railtie auto-inserts elevator middleware after `ActionDispatch::Callbacks` (just before cookies/sessions in full mode; works in API mode too).
116
116
 
117
117
  See the [Elevators](#elevators) section for available options.
118
118
 
@@ -217,7 +217,14 @@ Apartment.configure do |config|
217
217
  end
218
218
  ```
219
219
 
220
- The Railtie inserts the elevator as middleware automatically. You do not need `config.middleware.use` or `config.middleware.insert_before`.
220
+ The Railtie inserts the elevator after `ActionDispatch::Callbacks` automatically. In the full middleware stack this places it just before cookies, sessions, and authentication. In API mode (where cookies/sessions are absent), `Callbacks` is still present so the elevator works without changes.
221
+
222
+ If you need different positioning, skip `config.elevator` and insert manually:
223
+
224
+ ```ruby
225
+ # config/application.rb
226
+ config.middleware.insert_before 'Warden::Manager', Apartment::Elevators::Subdomain
227
+ ```
221
228
 
222
229
  ### Custom Elevator
223
230
 
@@ -304,6 +311,28 @@ Workaround: add `include Apartment::Model` and `pin_tenant` on the abstract clas
304
311
 
305
312
  The common pattern of `ApplicationRecord` using `connects_to` with multiple roles (writing/reading) on the same database works correctly; Apartment keys pools by `tenant:role` and respects Rails' role routing.
306
313
 
314
+ ## ActionController::Live Streaming
315
+
316
+ Apartment v4 handles tenant propagation across `ActionController::Live`'s spawned streaming thread automatically, under both `:thread` and `:fiber` isolation. Including `ActionController::Live` in your controller is sufficient — no additional configuration:
317
+
318
+ ```ruby
319
+ class StreamingController < ApplicationController
320
+ include ActionController::Live
321
+
322
+ def show
323
+ response.headers['Content-Type'] = 'text/event-stream'
324
+ # Apartment::Tenant.current returns the request's tenant here,
325
+ # even though we're now executing on the OS thread Rails spawned
326
+ # for streaming.
327
+ response.stream.write("data: #{{ tenant: Apartment::Tenant.current }.to_json}\n\n")
328
+ ensure
329
+ response.stream.close
330
+ end
331
+ end
332
+ ```
333
+
334
+ How it works: Apartment prepends `ActionController::Live#process` with a patch that backports [rails/rails#56902](https://github.com/rails/rails/pull/56902) to released Rails versions — it points `Thread.current.active_support_execution_state` at the request fiber's hash for the duration of the request, so Rails' own `share_with` carries all `CurrentAttributes` (apartment's tenant plus any app-defined ones) into the spawned streaming thread. User-spawned threads or fibers *inside* a Live action (`Thread.new`, `Async { }`, raw `Fiber.new`) escape the patch and need explicit `Apartment::Tenant.switch` wrapping. See the [upgrading guide](docs/upgrading-to-v4.md) and [`docs/designs/rails-boundary-tenancy.md`](docs/designs/rails-boundary-tenancy.md).
335
+
307
336
  ## Background Workers
308
337
 
309
338
  Use block-scoped switching in jobs:
@@ -323,6 +352,19 @@ For automatic tenant propagation:
323
352
  - [apartment-sidekiq](https://github.com/rails-on-services/apartment-sidekiq)
324
353
  - [apartment-activejob](https://github.com/rails-on-services/apartment-activejob)
325
354
 
355
+ A job that forgets to switch runs in the default tenant — for `Rails.cache` and
356
+ other `Tenant.current`-derived resources that silently contaminate another
357
+ tenant's keyspace. Guard routed work with `Apartment::Tenant.require_tenant!`
358
+ (raises unless a real, non-default tenant is active) and pinned/global work with
359
+ `require_default_tenant!`. See [Tenant-Aware Caching](docs/caching.md) for the
360
+ routed-vs-pinned model and the two-store recipe.
361
+
362
+ ## Convenience Methods
363
+
364
+ `Apartment.tenant_names` returns the current tenant list (delegates to `config.tenants_provider.call`). Preserves the v3 API so existing call sites work without changes.
365
+
366
+ `Apartment.excluded_models` returns the excluded models list (delegates to `config.excluded_models`). Deprecated in v4; use `Apartment::Model` + `pin_tenant` instead.
367
+
326
368
  ## Troubleshooting
327
369
 
328
370
  If tenant switching raises unexpected errors, verify that `tenants_provider` returns valid tenant names and that the tenant exists in the database.
@@ -331,6 +373,28 @@ If tenant switching raises unexpected errors, verify that `tenants_provider` ret
331
373
 
332
374
  See the [upgrade guide](docs/upgrading-to-v4.md) for a complete list of breaking changes and migration steps.
333
375
 
376
+ ## RuboCop cops
377
+
378
+ Apartment ships two optional RuboCop cops that enforce the block-form
379
+ tenant-switching discipline. Enable them in your application's `.rubocop.yml`:
380
+
381
+ ```yaml
382
+ require: rubocop/apartment
383
+ inherit_gem:
384
+ ros-apartment: config/default.yml
385
+ ```
386
+
387
+ - **`Apartment/NoDirectCurrentWrite`** (error) — bans assigning
388
+ `Apartment::Current.tenant` / `.previous_tenant` directly. Change tenant context
389
+ with `Apartment::Tenant.switch(tenant) { ... }` (or `with_default_tenant` for
390
+ global work), which guarantees a restore via `ensure`.
391
+ - **`Apartment/PreferBlockSwitch`** (warning) — nudges `Apartment::Tenant.switch!`
392
+ toward the block form. `reset` is not flagged.
393
+
394
+ Both match the qualified `Apartment::` receiver only. Scope them to your
395
+ application code with the standard `Exclude:` keys if needed. See
396
+ [`docs/designs/rubocop-cops.md`](docs/designs/rubocop-cops.md) for the rationale.
397
+
334
398
  ## Contributing
335
399
 
336
400
  1. Check [existing issues](https://github.com/rails-on-services/apartment/issues) and [discussions](https://github.com/rails-on-services/apartment/discussions)
@@ -0,0 +1,9 @@
1
+ Apartment/NoDirectCurrentWrite:
2
+ Description: 'Use the block-form switch instead of writing Apartment::Current directly.'
3
+ Enabled: true
4
+ Severity: error
5
+
6
+ Apartment/PreferBlockSwitch:
7
+ Description: 'Prefer the block-form Apartment::Tenant.switch over switch!.'
8
+ Enabled: true
9
+ Severity: warning
@@ -14,9 +14,9 @@ lib/apartment/
14
14
  │ ├── trilogy_adapter.rb # Database-per-tenant on MySQL (trilogy driver, inherits Mysql2Adapter)
15
15
  │ └── sqlite3_adapter.rb # File-per-tenant (FileUtils lifecycle)
16
16
  ├── concerns/ # ActiveRecord concerns for tenant-aware models
17
- │ └── model.rb # Apartment::Model concern: pin_tenant, apartment_pinned?
17
+ │ └── model.rb # Apartment::Model concern: pin_tenant, pinned identity, table-name helpers
18
18
  ├── configs/ # Database-specific config objects
19
- │ ├── postgresql_config.rb # PostgresqlConfig: persistent_schemas, enforce_search_path_reset
19
+ │ ├── postgresql_config.rb # PostgresqlConfig: persistent_schemas, include_schemas_in_dump
20
20
  │ └── mysql_config.rb # MysqlConfig: placeholder
21
21
  ├── elevators/ # Rack middleware for tenant detection (see CLAUDE.md); v4 uses constructor keyword args, no class-level state; Generic, Subdomain, FirstSubdomain, Domain, Host, HostHash, Header
22
22
  ├── patches/ # ActiveRecord patches for tenant-aware connections
@@ -42,6 +42,13 @@ lib/apartment/
42
42
 
43
43
  `switch(tenant) { ... }` sets `Current.tenant` via ensure block. Delegates lifecycle ops (`create`, `drop`, `migrate`, `seed`) to `Apartment.adapter`. No thread-local state — uses `CurrentAttributes` for fiber safety.
44
44
 
45
+ **Tenant-context guard family — two axes (#427)**:
46
+ - **Explicitness axis** (raw `Current.tenant`, ignores the `default_tenant` fallback): `tenant_switched?` / `assert_tenant_switched!(message:)`. Answers "did this code explicitly enter a tenant?" — test discipline. Renamed from `inside_tenant?` / `assert_inside_tenant!` (no aliases).
47
+ - **Identity axis** (effective `Tenant.current`, `to_s`-normalized): `in_tenant?` / `require_tenant!` (real, non-default; `require_tenant!` returns the name and raises `TenantRequired`), `in_default_tenant?` / `require_default_tenant!` (default; returns the name, raises `DefaultTenantRequired` in a real tenant or `DefaultTenantNotConfigured` when no default is set). `cache_namespace` wraps `require_tenant!` for fail-closed namespace procs; `with_default_tenant { }` enters the default/pinned context (guard-exempt, restore-on-raise). For runtime routed/pinned decisions (cache, jobs). See `docs/caching.md` and `docs/designs/tenant-aware-caching.md`.
48
+
49
+ **Strict-mode switch guard (4.0.0.alpha3)**:
50
+ - `switch(name) { ... }` calls `guard_default_tenant_switch!`, which raises when `Apartment.config.default_tenant_switch_allowed` is `false` AND `name == default_tenant`. `switch!`, `reset`, and `with_default_tenant` are exempt by design — they remain the unguarded paths back to the default tenant. The flag defaults to `true` (permissive) for all strategies; new PG `:schema` apps wanting strict semantics opt in. See `docs/testing.md`.
51
+
45
52
  ### config.rb — Configuration
46
53
 
47
54
  `Apartment.configure { |c| ... }` builds config, validates, freezes. Prepare-then-swap pattern: failed configure preserves previous working config. Frozen after validation — tests must reconfigure, not stub.
@@ -60,7 +67,7 @@ Background `Concurrent::TimerTask` instance that evicts idle and excess tenant p
60
67
 
61
68
  ### adapters/abstract_adapter.rb — Base Adapter
62
69
 
63
- Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`).
70
+ Lifecycle ops (`create`, `drop`, `migrate`, `seed`), `ActiveSupport::Callbacks` on `:create`/`:switch`, `resolve_connection_config` (abstract — subclasses override), `process_excluded_models`, `environmentify`, `base_config` (stringified `connection_config`), `rails_env` (guarded `Rails.env` access). `process_pinned_model` / `qualify_pinned_table_name` call **`Apartment::Model` class methods** (`apartment_explicit_table_name?`, `apartment_mark_processed!`, etc.); no `instance_variable_*` on arbitrary model classes. Constructor takes `connection_config` (raw AR hash, not `Apartment::Config`).
64
71
 
65
72
  ### Concrete Adapters (Phase 2.2)
66
73
 
@@ -74,7 +81,19 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
74
81
 
75
82
  ### concerns/model.rb — Model Pinning Concern
76
83
 
77
- `Apartment::Model` provides `pin_tenant` (class method) to declare a model as pinned to the default tenant. Registered models bypass the `ConnectionHandling` patch. Zeitwerk-safe: works whether called before or after `activate!`. `apartment_pinned?` checks the class and its superclass chain.
84
+ `Apartment::Model` provides `pin_tenant` (class method) to declare a model as pinned to the default tenant. Registered models bypass the `ConnectionHandling` patch when the adapter uses a separate pool; when shared pinned connections are enabled, routing follows the tenant pool (see design docs). Zeitwerk-safe: works whether called before or after `activate!`.
85
+
86
+ **Identity:** `apartment_pinned?` — the class answers whether it is pinned (ivars + superclass walk). `Apartment.pinned_model?(klass)` delegates to `klass.apartment_pinned?` when the concern is included; otherwise it falls back to registry lookup (`pinned_models`) for `excluded_models` shim classes that never included the concern.
87
+
88
+ **Table naming:** `apartment_explicit_table_name?` — whether `self.table_name` was explicitly set vs convention (compares `@table_name` to `compute_table_name`). Lives here so adapters do not read `@table_name` or call `compute_table_name` from outside; **class instance variable access for pinning is confined to this concern**.
89
+
90
+ **Lifecycle:** `apartment_pinned_processed?`, `apartment_mark_processed!`, `apartment_restore!` — qualification state and teardown. Adapters call these; `Apartment.clear_config` uses `apartment_restore!` with `respond_to?` so shim-registered models without the concern still clear safely. `apartment_mark_pinned!` — sets the pinned flag without triggering processing (used by `process_pinned_model` for shim classes to avoid `pin_tenant` recursion).
91
+
92
+ **Guards:** `pin_tenant` raises `ArgumentError` if called on a non-AR class or module. For anonymous classes (`Class.new`), it warns that `TracePoint(:end)` won't fire and skips deferral; call `process_pinned_model` explicitly after assigning the constant.
93
+
94
+ **Deferred processing:** When `Apartment.activated?` is true (Zeitwerk lazy-load path), `pin_tenant` defers `process_pinned_model` via `apartment_defer_processing!` — a one-shot `TracePoint(:end)` constrained to `Thread.current`. Only `:end` is used: `:b_return` fires for ALL block returns in class context (each, include hooks) and would trigger prematurely; `:raise` is not used because rescued raises still produce `:end` (MRI verified). For `Class.new { }` (tests, runtime metaprogramming), `:end` does not fire; call `process_pinned_model` explicitly. Models loaded before `activate!` are unaffected (processed in batch by `Tenant.init`). Reopening a pinned class does not re-trigger processing (idempotent via `apartment_pinned?`).
95
+
96
+ **Shim compatibility:** `process_pinned_model` dynamically includes `Apartment::Model` on classes registered via the `excluded_models` shim that lack the concern. This is a runtime `include` on a partially-booted class — acceptable for the legacy shim path but new code should always use `include Apartment::Model` + `pin_tenant` explicitly.
78
97
 
79
98
  ### railtie.rb — v4 Rails Integration
80
99
 
@@ -96,37 +96,83 @@ module Apartment
96
96
  end
97
97
  end
98
98
 
99
- # Process all pinned models establish separate connections pinned to default tenant.
99
+ # Whether pinned models can share the tenant's connection pool using
100
+ # qualified table names instead of establish_connection.
101
+ #
102
+ # Returns false by default (separate pool). Subclasses override to
103
+ # return true when the engine supports cross-schema/database queries,
104
+ # gated by config.force_separate_pinned_pool.
105
+ def shared_pinned_connection?
106
+ false
107
+ end
108
+
109
+ # Request-path fail-safe contract. The elevator wraps the
110
+ # tenant switch; on one of these error classes it asks
111
+ # #tenant_container_gone? whether the tenant's storage actually vanished (a
112
+ # cross-process drop) rather than an app-level failure. An empty list
113
+ # disables the rescue, so an adapter that does not implement the seams
114
+ # never converts an error into a 404.
115
+ def failsafe_error_classes
116
+ []
117
+ end
118
+
119
+ # Whether +error+, raised while serving +tenant+, means the tenant's
120
+ # container (schema/database/file) no longer exists — so the validator
121
+ # should evict the name and the request should 404 instead of surfacing a
122
+ # 500. Composed from a cheap error-shape check and an authoritative
123
+ # existence probe, both conservative by default so the base adapter never
124
+ # reclassifies. Subclasses override the seams.
125
+ def tenant_container_gone?(error, tenant)
126
+ return false unless container_error?(unwrap_db_error(error))
127
+
128
+ !tenant_container_exists?(tenant)
129
+ end
130
+
131
+ # Qualify a pinned model's table_name so it targets the default
132
+ # tenant's tables from any tenant connection. Subclasses must
133
+ # implement when shared_pinned_connection? returns true.
134
+ def qualify_pinned_table_name(_klass)
135
+ raise(NotImplementedError,
136
+ "#{self.class}#qualify_pinned_table_name must be implemented when shared_pinned_connection? is true")
137
+ end
138
+
139
+ # Process all pinned models. When shared_pinned_connection? is true, qualifies
140
+ # table names for shared pool routing. Otherwise, establishes separate connections.
100
141
  def process_pinned_models
101
142
  return if Apartment.pinned_models.empty?
102
143
 
103
144
  Apartment.pinned_models.each do |klass|
104
145
  process_pinned_model(klass)
146
+ rescue StandardError => e
147
+ raise(Apartment::ConfigurationError,
148
+ "Failed to process pinned model #{klass.name}: #{e.class}: #{e.message}")
105
149
  end
106
150
  end
107
151
 
108
152
  # Process a single pinned model. Called by process_pinned_models (batch)
109
153
  # and by Apartment::Model.pin_tenant (when activated? is true).
154
+ #
155
+ # When shared_pinned_connection? is true, qualifies the table name so
156
+ # the model uses the tenant's pool (preserving transactional integrity).
157
+ # Otherwise, establishes a separate connection pool (required when
158
+ # cross-database queries are impossible).
110
159
  def process_pinned_model(klass)
111
- # Idempotent: skip if already processed. Uses a class-level flag rather
112
- # than connection_specification_name comparison the spec name differs
113
- # from ActiveRecord::Base for ApplicationRecord subclasses even before
114
- # establish_connection, so it's not a reliable "already processed" signal.
115
- return if klass.instance_variable_get(:@apartment_connection_established)
116
-
117
- # Use base_config (the adapter's raw connection config) rather than
118
- # resolve_connection_config(default_tenant). For database-per-tenant
119
- # strategies (MySQL, SQLite), resolve_connection_config would set the
120
- # database key to the default tenant NAME (e.g. 'default'), not the
121
- # actual default database (e.g. 'apartment_v4_test'). base_config
122
- # points to the real default database.
123
- klass.establish_connection(base_config)
124
- klass.instance_variable_set(:@apartment_connection_established, true)
160
+ # Ensure the concern is included models registered via the
161
+ # excluded_models shim may not have it yet. Uses apartment_mark_pinned!
162
+ # (not pin_tenant) to avoid recursion back into process_pinned_model.
163
+ unless klass.respond_to?(:apartment_pinned_processed?)
164
+ klass.include(Apartment::Model)
165
+ klass.apartment_mark_pinned!
166
+ end
125
167
 
126
- return unless Apartment.config.tenant_strategy == :schema
168
+ return if klass.apartment_pinned_processed?
127
169
 
128
- table = klass.table_name.split('.').last
129
- klass.table_name = "#{default_tenant}.#{table}"
170
+ if shared_pinned_connection?
171
+ qualify_pinned_table_name(klass)
172
+ else
173
+ klass.establish_connection(pinned_model_config)
174
+ klass.apartment_mark_processed!
175
+ end
130
176
  end
131
177
 
132
178
  # Deprecated: use process_pinned_models instead.
@@ -169,6 +215,28 @@ module Apartment
169
215
 
170
216
  private
171
217
 
218
+ # --- Missing-tenant fail-safe seams -------------------------------------
219
+
220
+ # Does +error+ look like a missing-container error for this engine?
221
+ # Base: never, so the default adapter classifies nothing.
222
+ def container_error?(_error)
223
+ false
224
+ end
225
+
226
+ # Authoritative check that the tenant's container exists. Base: assume it
227
+ # does, so an unimplemented adapter never evicts a live tenant.
228
+ def tenant_container_exists?(_tenant)
229
+ true
230
+ end
231
+
232
+ # ConnectionHandling wraps non-Apartment errors raised during pool
233
+ # resolution in Apartment::ApartmentError with the original as #cause; the
234
+ # schema-strategy query error arrives unwrapped. Inspect the cause when
235
+ # present so both shapes classify the same.
236
+ def unwrap_db_error(error)
237
+ error.is_a?(Apartment::ApartmentError) && error.cause ? error.cause : error
238
+ end
239
+
172
240
  def grant_tenant_privileges(tenant)
173
241
  app_role = Apartment.config.app_role
174
242
  return unless app_role
@@ -191,6 +259,20 @@ module Apartment
191
259
  connection_config.transform_keys(&:to_s)
192
260
  end
193
261
 
262
+ # Connection config for pinned models on the separate-pool path.
263
+ # For schema strategy, pins schema_search_path to the default tenant
264
+ # (plus persistent schemas) so the connection resolves tables and FK
265
+ # constraints in the correct schema.
266
+ # For database strategies, returns base_config unchanged.
267
+ def pinned_model_config
268
+ config = base_config
269
+ return config unless Apartment.config.tenant_strategy == :schema
270
+
271
+ persistent = Apartment.config.postgres_config&.persistent_schemas || []
272
+ search_path = [default_tenant, *persistent].map { |s| %("#{s}") }.join(',')
273
+ config.merge('schema_search_path' => search_path)
274
+ end
275
+
194
276
  def rails_env
195
277
  unless defined?(Rails)
196
278
  raise(Apartment::ConfigurationError,
@@ -10,11 +10,41 @@ module Apartment
10
10
  # to the environmentified tenant name. Lifecycle operations (create/drop)
11
11
  # execute DDL against the default connection.
12
12
  class Mysql2Adapter < AbstractAdapter
13
+ def shared_pinned_connection?
14
+ !Apartment.config.force_separate_pinned_pool
15
+ end
16
+
17
+ def qualify_pinned_table_name(klass)
18
+ db_name = base_config['database']
19
+
20
+ if klass.apartment_explicit_table_name?
21
+ original = klass.table_name
22
+ table = original.sub(/\A[^.]+\./, '')
23
+ klass.table_name = "#{db_name}.#{table}"
24
+ klass.apartment_mark_processed!(:explicit, original)
25
+ else
26
+ original_prefix = klass.table_name_prefix
27
+ klass.table_name_prefix = "#{db_name}."
28
+ klass.reset_table_name
29
+ klass.apartment_mark_processed!(:convention, original_prefix)
30
+ end
31
+ end
32
+
13
33
  def resolve_connection_config(tenant, base_config: nil)
14
34
  config = base_config || send(:base_config)
15
35
  config.merge('database' => environmentify(tenant))
16
36
  end
17
37
 
38
+ # The database-per-tenant missing-tenant error: connecting to a dropped
39
+ # database raises ActiveRecord::NoDatabaseError (MySQL error 1049) — an
40
+ # unambiguous signal. It surfaces raw at query time, or wrapped in
41
+ # ApartmentError when ConnectionHandling resolves the pool (the dev-mode
42
+ # pending-migration check), so both are listed; #container_error? gates on
43
+ # the unwrapped NoDatabaseError. Inherited by TrilogyAdapter.
44
+ def failsafe_error_classes
45
+ [ActiveRecord::NoDatabaseError, Apartment::ApartmentError]
46
+ end
47
+
18
48
  protected
19
49
 
20
50
  def create_tenant(tenant)
@@ -38,6 +68,24 @@ module Apartment
38
68
  "GRANT SELECT, INSERT, UPDATE, DELETE ON #{connection.quote_table_name(db_name)}.* TO #{quoted_role}@'%'"
39
69
  )
40
70
  end
71
+
72
+ def container_error?(error)
73
+ error.is_a?(ActiveRecord::NoDatabaseError)
74
+ end
75
+
76
+ # Authoritative existence check on the DEFAULT connection:
77
+ # information_schema.schemata is server-global and reachable from any
78
+ # database, and the rescue runs after switch restored Current.tenant to
79
+ # default. The tenant's database is the environmentified name. A probe
80
+ # failure means we cannot prove it gone, so report it as existing and let
81
+ # the original error re-raise.
82
+ def tenant_container_exists?(tenant)
83
+ conn = ActiveRecord::Base.connection
84
+ quoted = conn.quote(environmentify(tenant))
85
+ !conn.select_value("SELECT 1 FROM information_schema.schemata WHERE schema_name = #{quoted}").nil?
86
+ rescue StandardError
87
+ true
88
+ end
41
89
  end
42
90
  end
43
91
  end
@@ -15,6 +15,16 @@ module Apartment
15
15
  config.merge('database' => environmentify(tenant))
16
16
  end
17
17
 
18
+ # The database-per-tenant missing-tenant error: connecting to a dropped
19
+ # database raises ActiveRecord::NoDatabaseError (PG SQLSTATE 3D000) — an
20
+ # unambiguous signal, unlike the schema strategy's 42P01. It surfaces raw at
21
+ # query time, or wrapped in ApartmentError when ConnectionHandling resolves
22
+ # the pool (the dev-mode pending-migration check), so both are listed;
23
+ # #container_error? gates on the unwrapped NoDatabaseError.
24
+ def failsafe_error_classes
25
+ [ActiveRecord::NoDatabaseError, Apartment::ApartmentError]
26
+ end
27
+
18
28
  protected
19
29
 
20
30
  def create_tenant(tenant)
@@ -42,6 +52,25 @@ module Apartment
42
52
  # (GRANT CONNECT on server, table grants inside tenant DB).
43
53
  # Use the callable app_role escape hatch for this strategy.
44
54
  # See docs/designs/v4-phase5-rbac-roles-schema-cache.md.
55
+
56
+ private
57
+
58
+ def container_error?(error)
59
+ error.is_a?(ActiveRecord::NoDatabaseError)
60
+ end
61
+
62
+ # Authoritative existence check on the DEFAULT connection: pg_database is a
63
+ # cluster-global catalog reachable from any database, and the rescue runs
64
+ # after switch restored Current.tenant to default. The tenant's database is
65
+ # the environmentified name. A probe failure means we cannot prove it gone,
66
+ # so report it as existing and let the original error re-raise.
67
+ def tenant_container_exists?(tenant)
68
+ conn = ActiveRecord::Base.connection
69
+ quoted = conn.quote(environmentify(tenant))
70
+ !conn.select_value("SELECT 1 FROM pg_database WHERE datname = #{quoted}").nil?
71
+ rescue StandardError
72
+ true
73
+ end
45
74
  end
46
75
  end
47
76
  end
@@ -12,14 +12,48 @@ module Apartment
12
12
  # Apartment.config.postgres_config. Lifecycle operations (create/drop)
13
13
  # execute DDL against the default connection.
14
14
  class PostgresqlSchemaAdapter < AbstractAdapter
15
+ def shared_pinned_connection?
16
+ !Apartment.config.force_separate_pinned_pool
17
+ end
18
+
19
+ def qualify_pinned_table_name(klass)
20
+ if klass.apartment_explicit_table_name?
21
+ original = klass.table_name
22
+ table = original.sub(/\A[^.]+\./, '')
23
+ klass.table_name = "#{default_tenant}.#{table}"
24
+ klass.apartment_mark_processed!(:explicit, original)
25
+ else
26
+ original_prefix = klass.table_name_prefix
27
+ klass.table_name_prefix = "#{default_tenant}."
28
+ klass.reset_table_name
29
+ klass.apartment_mark_processed!(:convention, original_prefix)
30
+ end
31
+ end
32
+
15
33
  def resolve_connection_config(tenant, base_config: nil)
16
34
  config = base_config || send(:base_config)
17
35
  persistent = Apartment.config.postgres_config&.persistent_schemas || []
18
- search_path = [tenant, *persistent].join(',')
36
+ search_path = [tenant, *persistent].map { |s| %("#{s}") }.join(',')
19
37
 
20
38
  config.merge('schema_search_path' => search_path)
21
39
  end
22
40
 
41
+ # The schema-strategy missing-tenant error: a dropped schema is not caught
42
+ # at switch time (search_path accepts a non-existent schema silently) — it
43
+ # surfaces on the first query as ActiveRecord::StatementInvalid
44
+ # (PG::UndefinedTable, 42P01). That is the same shape as a missing table in
45
+ # a *live* schema, so #tenant_container_exists? does the disambiguating
46
+ # to_regnamespace check.
47
+ #
48
+ # ApartmentError is included because ConnectionHandling wraps errors raised
49
+ # during pool resolution (e.g. the dev-mode pending-migration check, which
50
+ # queries schema_migrations in the gone schema) as ApartmentError with the
51
+ # StatementInvalid as #cause; #container_error? then unwraps and classifies
52
+ # it the same as the query-time case, and re-raises any other ApartmentError.
53
+ def failsafe_error_classes
54
+ [ActiveRecord::StatementInvalid, Apartment::ApartmentError]
55
+ end
56
+
23
57
  protected
24
58
 
25
59
  def create_tenant(tenant)
@@ -34,6 +68,27 @@ module Apartment
34
68
 
35
69
  private
36
70
 
71
+ # Any StatementInvalid is a candidate; the authoritative call is the
72
+ # existence probe below, so a missing table in a live schema (same 42P01)
73
+ # correctly re-raises rather than 404ing.
74
+ def container_error?(error)
75
+ error.is_a?(ActiveRecord::StatementInvalid)
76
+ end
77
+
78
+ # Authoritative existence check, run on the DEFAULT connection: the
79
+ # elevator's switch ensure-block has already restored Current.tenant before
80
+ # the fail-safe rescue runs, so ActiveRecord::Base.connection targets the
81
+ # default pool rather than the gone tenant. to_regnamespace returns NULL for
82
+ # a missing schema. If the probe itself errors (e.g. the database is down),
83
+ # we cannot prove the schema is gone — report it as existing so the original
84
+ # error re-raises instead of masking infrastructure failure as a 404.
85
+ def tenant_container_exists?(tenant)
86
+ conn = ActiveRecord::Base.connection
87
+ conn.select_value("SELECT to_regnamespace(#{conn.quote(tenant)}) IS NOT NULL")
88
+ rescue StandardError
89
+ true
90
+ end
91
+
37
92
  def grant_privileges(tenant, connection, role_name) # rubocop:disable Metrics/MethodLength
38
93
  quoted_schema = connection.quote_table_name(tenant)
39
94
  quoted_role = connection.quote_table_name(role_name)
@@ -18,6 +18,20 @@ module Apartment
18
18
  config.merge('database' => File.join(db_dir, "#{environmentify(tenant)}.sqlite3"))
19
19
  end
20
20
 
21
+ # No missing-tenant fail-safe override on purpose — keep the conservative
22
+ # AbstractAdapter default (failsafe_error_classes == []). SQLite gives no
23
+ # sound "container gone" signal: connecting to a dropped file auto-recreates
24
+ # it empty, so by the time the elevator's rescue runs File.exist? is true
25
+ # and the only query error is "no such table" (StatementInvalid) — identical
26
+ # to a missing table in a live tenant, or to a freshly created tenant with
27
+ # no schema loaded (schema_load_strategy nil). A zero-tables heuristic would
28
+ # 404 valid-but-empty tenants; there is no authoritative catalog (unlike
29
+ # pg_database / information_schema) to distinguish dropped from unpopulated.
30
+ # Auto-create is also load-bearing — create_tenant relies on it — so it
31
+ # cannot be disabled to force a clean missing-file error. SQLite file-per-
32
+ # tenant is a dev/test strategy, not a multi-process target, so the
33
+ # cross-process drop gap this guards barely applies. See the design doc.
34
+
21
35
  protected
22
36
 
23
37
  def create_tenant(tenant)
@@ -79,7 +79,7 @@ module Apartment
79
79
  end
80
80
 
81
81
  def rollback_all
82
- tenants = Apartment.config.tenants_provider.call
82
+ tenants = Apartment.tenant_names
83
83
  failed = []
84
84
  tenants.each do |t|
85
85
  rollback_single(t)
@@ -29,7 +29,7 @@ module Apartment
29
29
  end
30
30
 
31
31
  def seed_all
32
- tenants = Apartment.config.tenants_provider.call
32
+ tenants = Apartment.tenant_names
33
33
  failed = []
34
34
  tenants.each do |t|
35
35
  say("Seeding tenant: #{t}")
@@ -37,7 +37,7 @@ module Apartment
37
37
 
38
38
  desc 'list', 'List all tenants'
39
39
  def list
40
- Apartment.config.tenants_provider.call.each { |t| say(t) }
40
+ Apartment.tenant_names.each { |t| say(t) }
41
41
  end
42
42
 
43
43
  desc 'current', 'Show current tenant'
@@ -56,7 +56,7 @@ module Apartment
56
56
  end
57
57
 
58
58
  def create_all
59
- tenants = Apartment.config.tenants_provider.call
59
+ tenants = Apartment.tenant_names
60
60
  failed = []
61
61
  tenants.each do |t|
62
62
  say("Creating tenant: #{t}") unless quiet?