Class: Apartment::Adapters::AbstractAdapter
- Inherits:
-
Object
- Object
- Apartment::Adapters::AbstractAdapter
- Includes:
- ActiveSupport::Callbacks
- Defined in:
- lib/apartment/adapters/abstract_adapter.rb
Overview
rubocop:disable Metrics/ClassLength
Direct Known Subclasses
Mysql2Adapter, PostgresqlDatabaseAdapter, PostgresqlSchemaAdapter, Sqlite3Adapter
Instance Attribute Summary collapse
-
#connection_config ⇒ Object
readonly
The raw database connection configuration hash (from ActiveRecord).
Instance Method Summary collapse
-
#aborted_transaction?(_conn) ⇒ Boolean
Whether
connsits in an aborted-transaction state that every subsequent statement will fail against until the transaction ends. - #apply_pinned_qualification(klass) ⇒ Object
-
#check_pinned_subclass(klass, sub, registered) ⇒ Object
Advisory only: this runs from process_pinned_models, which Tenant.init calls in after_initialize, so it must never be able to fail a boot.
-
#create(tenant) ⇒ Object
Create a new tenant (schema or database).
-
#default_tenant ⇒ Object
Default tenant from config.
-
#drop(tenant) ⇒ Object
Drop a tenant.
-
#environmentify(tenant) ⇒ Object
Environmentify a tenant name based on config.
-
#failsafe_error_classes ⇒ Object
Request-path fail-safe contract.
-
#inherits_pinned_table?(klass) ⇒ Boolean
Whether
klassreaches its table through an already-pinned base class and so needs no qualification of its own. -
#initialize(connection_config) ⇒ AbstractAdapter
constructor
A new instance of AbstractAdapter.
-
#migrate(tenant, version = nil) ⇒ Object
Run migrations for a tenant.
-
#nearest_pinned_ancestor(klass, registered) ⇒ Object
The closest registered ancestor above
klass, i.e. -
#physical_tenant_name(tenant) ⇒ Object
The physical identifier used to address this tenant at connection time: the database name for database-per-tenant strategies (environmentified).
-
#pinned_table_qualifier ⇒ Object
The namespace that makes the default tenant's tables reachable from any tenant connection — a schema (PostgreSQL) or a database (MySQL).
-
#process_excluded_models ⇒ Object
Deprecated: use process_pinned_models instead.
-
#process_pinned_model(klass) ⇒ Object
Process a single pinned model.
-
#process_pinned_models ⇒ Object
Process all pinned models.
-
#qualify_pinned_table_name(klass) ⇒ Object
Qualify a pinned model's table_name so it targets the default tenant's tables from any tenant connection.
-
#qualify_pinned_table_name_prefix(klass) ⇒ Object
An abstract class has no table of its own — table_name is nil — so there is nothing to assign.
-
#resolve_connection_config(tenant, base_config: nil) ⇒ Object
Resolve a tenant-specific connection config hash.
-
#seed(tenant) ⇒ Object
Run seeds for a tenant.
-
#shared_pinned_connection? ⇒ Boolean
Whether pinned models can share the tenant's connection pool using qualified table names instead of establish_connection.
-
#tenant_container_gone?(error, tenant) ⇒ Boolean
Whether
error, raised while servingtenant, means the tenant's container (schema/database/file) no longer exists — so the validator should evict the name and the request should 404 instead of surfacing a 500. - #unregistered_pinned_subclass?(sub, registered) ⇒ Boolean
-
#validated_connection_config(tenant, base_config_override: nil) ⇒ Object
Template method: validates tenant name then delegates to resolve_connection_config.
- #warn_unqualified_subclass(klass, sub) ⇒ Object
-
#warn_unregistered_pinned_subclasses ⇒ Object
Warn about subclasses of a pinned model that declare their own table and were never registered.
Constructor Details
#initialize(connection_config) ⇒ AbstractAdapter
Returns a new instance of AbstractAdapter.
18 19 20 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 18 def initialize(connection_config) @connection_config = connection_config end |
Instance Attribute Details
#connection_config ⇒ Object (readonly)
The raw database connection configuration hash (from ActiveRecord). Not to be confused with Apartment.config (the Apartment::Config object).
16 17 18 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 16 def connection_config @connection_config end |
Instance Method Details
#aborted_transaction?(_conn) ⇒ Boolean
Whether conn sits in an aborted-transaction state that every subsequent
statement will fail against until the transaction ends. PostgreSQL is the
only supported engine with such a state (PQTRANS_INERROR); MySQL fails the
statement and leaves the transaction usable, and its raw connection has no
transaction_status at all. Base is conservative: never reclassify.
See docs/designs/transaction-taint-detection.md (Evidence E).
125 126 127 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 125 def aborted_transaction?(_conn) false end |
#apply_pinned_qualification(klass) ⇒ Object
187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 187 def apply_pinned_qualification(klass) return qualify_pinned_table_name_prefix(klass) if klass.abstract_class? return klass.apartment_mark_processed! if inherits_pinned_table?(klass) # Captured before the assignment below, which would otherwise make # every model look explicitly named. path = klass.apartment_explicit_table_name? ? :explicit : :computed original = klass.table_name klass.table_name = "#{pinned_table_qualifier}.#{original.sub(/\A[^.]+\./, '')}" klass.apartment_mark_processed!(path, (original if path == :explicit)) end |
#check_pinned_subclass(klass, sub, registered) ⇒ Object
Advisory only: this runs from process_pinned_models, which Tenant.init calls in after_initialize, so it must never be able to fail a boot. Rails' naming machinery raises on shapes we do not control — an anonymous descendant has no model_name, and Class.new(SomeBase) is everywhere in test suites.
299 300 301 302 303 304 305 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 299 def check_pinned_subclass(klass, sub, registered) return unless unregistered_pinned_subclass?(sub, registered) warn_unqualified_subclass(nearest_pinned_ancestor(sub, registered) || klass, sub) rescue StandardError => e warn "[Apartment] could not check pinned subclass #{sub.inspect}: #{e.class}: #{e.}" end |
#create(tenant) ⇒ Object
Create a new tenant (schema or database). Validates the physical identifier create_tenant actually addresses (raw schema name for :schema, environmentified database name otherwise), so create and the pool-resolution path validate the same name.
54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 54 def create(tenant) validate_pool_key_safety!(tenant) TenantNameValidator.validate!( physical_tenant_name(tenant), strategy: Apartment.config.tenant_strategy, adapter_name: base_config['adapter'] ) run_callbacks(:create) do create_tenant(tenant) grant_tenant_privileges(tenant) import_schema(tenant) if Apartment.config.schema_load_strategy seed(tenant) if Apartment.config.seed_after_create Instrumentation.instrument(:create, tenant: tenant) end end |
#default_tenant ⇒ Object
Default tenant from config.
403 404 405 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 403 def default_tenant Apartment.config.default_tenant end |
#drop(tenant) ⇒ Object
Drop a tenant.
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 71 def drop(tenant) drop_tenant(tenant) removed_pools = Apartment.pool_manager&.remove_tenant(tenant) || [] removed_pools.each do |pool_key, pool| # remove_tenant already took these out of the manager, so deregister_shard's # own removal returns nil and cannot disconnect them — we close them here. Apartment.disconnect_removed_pool(pool, pool_key) begin deregister_shard_from_ar_handler(pool_key) rescue StandardError => e warn "[Apartment] Shard deregistration failed for '#{pool_key}': #{e.class}: #{e.}" end end Instrumentation.instrument(:drop, tenant: tenant) end |
#environmentify(tenant) ⇒ Object
Environmentify a tenant name based on config. :prepend/:append require Rails to be defined (for Rails.env).
379 380 381 382 383 384 385 386 387 388 389 390 391 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 379 def environmentify(tenant) case Apartment.config.environmentify_strategy when :prepend "#{rails_env}_#{tenant}" when :append "#{tenant}_#{rails_env}" when nil tenant.to_s else # Callable Apartment.config.environmentify_strategy.call(tenant) end end |
#failsafe_error_classes ⇒ Object
Request-path fail-safe contract. The elevator wraps the tenant switch; on one of these error classes it asks #tenant_container_gone? whether the tenant's storage actually vanished (a cross-process drop) rather than an app-level failure. An empty list disables the rescue, so an adapter that does not implement the seams never converts an error into a 404.
135 136 137 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 135 def failsafe_error_classes [] end |
#inherits_pinned_table?(klass) ⇒ Boolean
Whether klass reaches its table through an already-pinned base class
and so needs no qualification of its own. Rails resolves a subclass's
table through base_class.table_name, which the base's qualification
already covers; assigning here would freeze a copy of the base's
qualified name onto the child and desynchronise the two on teardown.
Scoped narrowly on purpose. A subclass that declares its own table — the transitional shape when migrating an STI child off a pinned parent's table — is NOT covered and qualifies normally, because the parent's qualification cannot reach a different table. And a subclass whose base class is not pinned (e.g. an app model extending a gem's model) is not covered either, which is the case that motivated qualifying by assignment in the first place.
235 236 237 238 239 240 241 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 235 def inherits_pinned_table?(klass) return false if klass.base_class? return false if klass.apartment_explicit_table_name? base = klass.base_class base.respond_to?(:apartment_pinned?) && base.apartment_pinned? end |
#migrate(tenant, version = nil) ⇒ Object
Run migrations for a tenant.
88 89 90 91 92 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 88 def migrate(tenant, version = nil) Apartment::Tenant.switch(tenant) do ActiveRecord::Base.connection_pool.migration_context.migrate(version) end end |
#nearest_pinned_ancestor(klass, registered) ⇒ Object
The closest registered ancestor above klass, i.e. the pin it inherits.
308 309 310 311 312 313 314 315 316 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 308 def nearest_pinned_ancestor(klass, registered) ancestor = klass.superclass while ancestor.is_a?(Class) && ancestor < ActiveRecord::Base return ancestor if registered.include?(ancestor) ancestor = ancestor.superclass end nil end |
#physical_tenant_name(tenant) ⇒ Object
The physical identifier used to address this tenant at connection time: the database name for database-per-tenant strategies (environmentified). validated_connection_config validates THIS name so the pool-resolution path agrees with what the connection actually targets. Schema-per-tenant overrides this to the raw tenant (schemas are named directly).
398 399 400 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 398 def physical_tenant_name(tenant) environmentify(tenant) end |
#pinned_table_qualifier ⇒ Object
The namespace that makes the default tenant's tables reachable from any tenant connection — a schema (PostgreSQL) or a database (MySQL). Subclasses must implement when shared_pinned_connection? returns true.
154 155 156 157 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 154 def pinned_table_qualifier raise(NotImplementedError, "#{self.class}#pinned_table_qualifier must be implemented when shared_pinned_connection? is true") end |
#process_excluded_models ⇒ Object
Deprecated: use process_pinned_models instead.
371 372 373 374 375 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 371 def process_excluded_models warn '[Apartment] DEPRECATION: process_excluded_models is deprecated. ' \ 'Use Apartment::Model with pin_tenant instead.' process_pinned_models end |
#process_pinned_model(klass) ⇒ Object
Process a single pinned model. Called by process_pinned_models (batch) and by Apartment::Model.pin_tenant (when activated? is true).
When shared_pinned_connection? is true, qualifies the table name so the model uses the tenant's pool (preserving transactional integrity). Otherwise, establishes a separate connection pool (required when cross-database queries are impossible).
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 343 def process_pinned_model(klass) # Ensure the concern is included — models registered via the # excluded_models shim may not have it yet. Uses apartment_mark_pinned! # (not pin_tenant) to avoid recursion back into process_pinned_model. unless klass.respond_to?(:apartment_pinned_processed?) klass.include(Apartment::Model) klass.apartment_mark_pinned! end return if klass.apartment_pinned_processed? # A subclass that reaches its table through an already-pinned base needs # nothing on either path. Qualifying would freeze a copy of the base's # name onto it; establishing a connection would hand it a *different* # pool from its parent, splitting two classes that share one physical # table across connections and breaking transactional integrity between # them. Mark processed with a nil path so teardown skips it too. return klass.apartment_mark_processed! if inherits_pinned_table?(klass) if shared_pinned_connection? qualify_pinned_table_name(klass) else klass.establish_connection(pinned_model_config) klass.apartment_mark_processed! end end |
#process_pinned_models ⇒ Object
Process all pinned models. When shared_pinned_connection? is true, qualifies table names for shared pool routing. Otherwise, establishes separate connections.
245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 245 def process_pinned_models return if Apartment.pinned_models.empty? Apartment.pinned_models.each do |klass| process_pinned_model(klass) rescue StandardError => e raise(Apartment::ConfigurationError, "Failed to process pinned model #{klass.name}: #{e.class}: #{e.}") end warn_unregistered_pinned_subclasses end |
#qualify_pinned_table_name(klass) ⇒ Object
Qualify a pinned model's table_name so it targets the default tenant's tables from any tenant connection.
Always assigns table_name directly. The tempting alternative — set table_name_prefix and let Rails recompose — is unsound, because compute_table_name only consults full_table_name_prefix on its base_class? branch:
* a class that is not its own base_class gets base_class.table_name
verbatim, so the prefix is discarded outright;
* full_table_name_prefix prefers the first module parent that responds
to table_name_prefix, so an engine-namespaced model ignores the
prefix set on the class itself;
* overwriting the prefix drops one the app set, silently retargeting
the model at a different table.
Each case left the model resolving to the tenant's table with no error. Reading table_name first lets Rails compute the conventional name — honouring any prefix, suffix, or nesting the app declared — before we qualify the result.
179 180 181 182 183 184 185 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 179 def qualify_pinned_table_name(klass) # Captured before the mutation below: afterwards there is no way to # tell a descendant's stale memo from a table it declared itself. inheriting = klass.apartment_descendants_inheriting_table_name apply_pinned_qualification(klass) klass.apartment_resync_descendant_table_names!(inheriting) end |
#qualify_pinned_table_name_prefix(klass) ⇒ Object
An abstract class has no table of its own — table_name is nil — so
there is nothing to assign. Pinning one is a supported pattern (an
abstract connects_to base is pinned so Apartment does not build
tenant pools for it), and its qualifier still has to reach the concrete
descendants that inherit the pin.
Those descendants are never qualified directly: pin_tenant early-returns
once any superclass is pinned (apartment_pinned? walks the chain), so
they are never registered and process_pinned_models never sees them.
table_name_prefix is a class_attribute, so setting it here broadcasts
down the inheritance chain and each descendant composes it in its own
compute_table_name. Any prefix the app set is preserved rather than
overwritten, so myapp_ becomes <qualifier>.myapp_.
This is the one place the prefix mechanism is still correct, because here it is a broadcast to other classes rather than an attempt to qualify this class's own name.
216 217 218 219 220 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 216 def qualify_pinned_table_name_prefix(klass) original_prefix = klass.table_name_prefix klass.table_name_prefix = "#{pinned_table_qualifier}.#{original_prefix}" klass.apartment_mark_processed!(:prefix, original_prefix) end |
#resolve_connection_config(tenant, base_config: nil) ⇒ Object
Resolve a tenant-specific connection config hash. Subclasses override to set strategy-specific keys.
46 47 48 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 46 def resolve_connection_config(tenant, base_config: nil) raise(NotImplementedError) end |
#seed(tenant) ⇒ Object
Run seeds for a tenant.
95 96 97 98 99 100 101 102 103 104 105 106 107 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 95 def seed(tenant) Apartment::Tenant.switch(tenant) do seed_file = Apartment.config.seed_data_file return unless seed_file unless File.exist?(seed_file) raise(Apartment::ConfigurationError, "Seed file '#{seed_file}' does not exist") end load(seed_file) end end |
#shared_pinned_connection? ⇒ Boolean
Whether pinned models can share the tenant's connection pool using qualified table names instead of establish_connection.
Returns false by default (separate pool). Subclasses override to return true when the engine supports cross-schema/database queries, gated by config.force_separate_pinned_pool.
115 116 117 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 115 def shared_pinned_connection? false end |
#tenant_container_gone?(error, tenant) ⇒ Boolean
Whether error, raised while serving tenant, means the tenant's
container (schema/database/file) no longer exists — so the validator
should evict the name and the request should 404 instead of surfacing a
500. Composed from a cheap error-shape check and an authoritative
existence probe, both conservative by default so the base adapter never
reclassifies. Subclasses override the seams.
145 146 147 148 149 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 145 def tenant_container_gone?(error, tenant) return false unless container_error?(unwrap_db_error(error)) !tenant_container_exists?(tenant) end |
#unregistered_pinned_subclass?(sub, registered) ⇒ Boolean
318 319 320 321 322 323 324 325 326 327 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 318 def unregistered_pinned_subclass?(sub, registered) return false if registered.include?(sub) # Anonymous classes have no model_name for Rails to compute a table # from, and nothing actionable to name in a warning. return false if sub.name.nil? return false unless sub.respond_to?(:apartment_explicit_table_name?) return false if sub.abstract_class? sub.apartment_explicit_table_name? end |
#validated_connection_config(tenant, base_config_override: nil) ⇒ Object
Template method: validates tenant name then delegates to resolve_connection_config. Called by ConnectionHandling — subclasses should NOT override this. base_config_override: when supplied (e.g. a role-specific config from ConnectionHandling), the adapter builds the tenant config on top of it instead of its own base_config.
This validates only the PHYSICAL identifier (engine rules). Raw pool-key safety (colon/whitespace/NUL that would corrupt "tenant:role") is enforced by the sole production caller, ConnectionHandling#connection_pool, before it builds the pool key — and independently by #create. A future caller that invokes this directly, bypassing connection_pool, must validate the raw tenant itself (TenantNameValidator.validate_common!).
33 34 35 36 37 38 39 40 41 42 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 33 def validated_connection_config(tenant, base_config_override: nil) effective_base = base_config_override || base_config TenantNameValidator.validate!( physical_tenant_name(tenant), strategy: Apartment.config.tenant_strategy, adapter_name: effective_base['adapter'] ) config = resolve_connection_config(tenant, base_config: effective_base) apply_tenant_pool_size(config) end |
#warn_unqualified_subclass(klass, sub) ⇒ Object
329 330 331 332 333 334 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 329 def warn_unqualified_subclass(klass, sub) warn "[Apartment] #{sub.name || sub.inspect} inherits a pin from " \ "#{klass.name || klass.inspect} but declares its own table " \ "(#{sub.table_name}) and was never registered, so it is not qualified. " \ "Call pin_tenant on it if it should read the default tenant's data." end |
#warn_unregistered_pinned_subclasses ⇒ Object
Warn about subclasses of a pinned model that declare their own table and were never registered. Such a class inherits apartment_pinned? through the superclass walk but gets no qualification, so on a shared-connection adapter it silently reads the tenant's table — and on a separate-pool adapter a genuinely tenant-scoped one silently reads the default's. The shape is transitional (migrating an STI child off a pinned parent's table), which is exactly when a silent read is most costly: the symptom looks like a botched backfill.
Detection walks descendants, so it is complete under eager loading (production boot, CI) and partial under Zeitwerk lazy loading. That is tolerable for a warning and would not be for a raise — which is why this warns rather than raising. descendants is transitive, so every pinned class in one inheritance chain sees the same unregistered descendant. Deduplicate, and attribute each warning to the nearest pinned ancestor — the one whose pin the subclass actually inherits — so the message is deterministic rather than dependent on registry iteration order.
276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 |
# File 'lib/apartment/adapters/abstract_adapter.rb', line 276 def warn_unregistered_pinned_subclasses # Snapshot the registry and walk it outside its own lock. Concurrent::Set # synchronizes every method on CRuby, #each included, so iterating in # place would hold a process-wide monitor across descendant walking and # stderr I/O. Same leaf-lock discipline as Patches::ConnectionRegistry. pinned = Apartment.pinned_models.to_a registered = Set.new(pinned) seen = Set.new pinned.each do |klass| next unless klass.respond_to?(:descendants) klass.descendants.each do |sub| check_pinned_subclass(klass, sub, registered) if seen.add?(sub) end end end |