Class: Apartment::Adapters::AbstractAdapter

Inherits:
Object
  • Object
show all
Includes:
ActiveSupport::Callbacks
Defined in:
lib/apartment/adapters/abstract_adapter.rb

Overview

rubocop:disable Metrics/ClassLength

Instance Attribute Summary collapse

Instance Method Summary collapse

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_configObject (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).

Returns:

  • (Boolean)


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.message}"
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_tenantObject

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.message}"
    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_classesObject

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.

Returns:

  • (Boolean)


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_qualifierObject

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.

Raises:

  • (NotImplementedError)


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_modelsObject

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_modelsObject

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.message}")
  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.

Raises:

  • (NotImplementedError)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)


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

Returns:

  • (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_subclassesObject

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