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)


129
130
131
# File 'lib/apartment/adapters/abstract_adapter.rb', line 129

def aborted_transaction?(_conn)
  false
end

#apply_pinned_qualification(klass) ⇒ Object



227
228
229
230
231
232
233
234
235
236
237
# File 'lib/apartment/adapters/abstract_adapter.rb', line 227

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

#awaiting_own_qualification?(klass) ⇒ Boolean

A descendant that is registered but not yet processed gets qualified on its own turn, by direct assignment, which reaches names the ancestor's broadcast cannot. Registry order is always parent-first — defining a subclass loads its parent, and pin_tenant registers during class-body execution — so verifying the base's descendants would otherwise raise on a model that is about to become correct, aborting the very iteration that would have fixed it. That is the remedy this check's own error message prescribes, so it has to keep working.

Returns:

  • (Boolean)


338
339
340
# File 'lib/apartment/adapters/abstract_adapter.rb', line 338

def awaiting_own_qualification?(klass)
  Apartment.pinned_models.include?(klass) && !klass.apartment_pinned_processed?
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.



451
452
453
454
455
456
457
# File 'lib/apartment/adapters/abstract_adapter.rb', line 451

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']
  )
  suppressing_pending_migration_check do
    run_callbacks(:create) do
      run_tenant_ddl(tenant)
      seed(tenant) if Apartment.config.seed_after_create
      Instrumentation.instrument(:create, tenant: tenant)
    end
  end
end

#current_db_role(_connection) ⇒ Object

The executing database role, for policies that need to name it explicitly (PostgreSQL's ALTER DEFAULT PRIVILEGES FOR ROLE). nil where the engine has no role system. Token shape differs by engine, so each adapter answers.



174
175
176
# File 'lib/apartment/adapters/abstract_adapter.rb', line 174

def current_db_role(_connection)
  nil
end

#default_tenantObject

Default tenant from config.



555
556
557
# File 'lib/apartment/adapters/abstract_adapter.rb', line 555

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
86
87
88
89
# File 'lib/apartment/adapters/abstract_adapter.rb', line 71

def drop(tenant)
  # Wrapped for the same reason create is: the container is owned by ddl_role,
  # and DROP SCHEMA requires ownership, so the writing role generally cannot drop
  # what the gem created. Only the engine call — the pool removal and shard
  # deregistration below are local bookkeeping and need no role.
  MigrationRole.wrap { 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).



531
532
533
534
535
536
537
538
539
540
541
542
543
# File 'lib/apartment/adapters/abstract_adapter.rb', line 531

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.



139
140
141
# File 'lib/apartment/adapters/abstract_adapter.rb', line 139

def failsafe_error_classes
  []
end

#inheriting_descendants(klass) ⇒ Object



316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/apartment/adapters/abstract_adapter.rb', line 316

def inheriting_descendants(klass)
  return [] unless klass.respond_to?(:descendants)

  klass.descendants.select do |sub|
    sub.respond_to?(:apartment_inherited_table_name) &&
      !awaiting_own_qualification?(sub) &&
      sub.table_name == sub.apartment_inherited_table_name
  rescue StandardError => e
    warn "[Apartment] could not classify pinned descendant #{sub.name || sub.inspect}: " \
         "#{e.class}: #{e.message}"
    false
  end
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)


387
388
389
390
391
392
393
# File 'lib/apartment/adapters/abstract_adapter.rb', line 387

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.



92
93
94
95
96
# File 'lib/apartment/adapters/abstract_adapter.rb', line 92

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.



460
461
462
463
464
465
466
467
468
# File 'lib/apartment/adapters/abstract_adapter.rb', line 460

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



550
551
552
# File 'lib/apartment/adapters/abstract_adapter.rb', line 550

def physical_tenant_name(tenant)
  environmentify(tenant)
end

#pinned_qualification_mutates?(klass) ⇒ Boolean

Whether qualifying klass will actually change its naming. False for the branch that deliberately mutates nothing, which must not be verified: a subclass sharing an already-pinned base's table is correct by construction, and if its base has not been qualified yet (registry order) it is about to become so.

Returns:

  • (Boolean)


223
224
225
# File 'lib/apartment/adapters/abstract_adapter.rb', line 223

def pinned_qualification_mutates?(klass)
  klass.abstract_class? || !inherits_pinned_table?(klass)
end

#pinned_table_name_for(model) ⇒ Object

A model whose table_name raises cannot be verified. Say so rather than skipping silently: the thesis of this check is proving rather than assuming, and an unverifiable model is exactly what it must not wave through unremarked.



295
296
297
298
299
300
301
# File 'lib/apartment/adapters/abstract_adapter.rb', line 295

def pinned_table_name_for(model)
  model.table_name
rescue StandardError => e
  warn '[Apartment] could not verify the pinned table name for ' \
       "#{model.name || model.inspect}: #{e.class}: #{e.message}"
  nil
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)


181
182
183
184
# File 'lib/apartment/adapters/abstract_adapter.rb', line 181

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.



523
524
525
526
527
# File 'lib/apartment/adapters/abstract_adapter.rb', line 523

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



495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
# File 'lib/apartment/adapters/abstract_adapter.rb', line 495

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.



397
398
399
400
401
402
403
404
405
406
407
408
# File 'lib/apartment/adapters/abstract_adapter.rb', line 397

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.

In each case the model resolves to the tenant's table and nothing raises. Reading table_name first lets Rails compute the conventional name — honouring any prefix, suffix, or nesting the app declared — before we qualify the result.



206
207
208
209
210
211
212
213
214
215
216
# File 'lib/apartment/adapters/abstract_adapter.rb', line 206

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
  # Evaluated before the mutation, which is what inherits_pinned_table?
  # inspects.
  mutates = pinned_qualification_mutates?(klass)
  apply_pinned_qualification(klass)
  klass.apartment_resync_descendant_table_names!(inheriting)
  verify_pinned_qualification!(klass) if mutates
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.



368
369
370
371
372
# File 'lib/apartment/adapters/abstract_adapter.rb', line 368

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.



99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/apartment/adapters/abstract_adapter.rb', line 99

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)


119
120
121
# File 'lib/apartment/adapters/abstract_adapter.rb', line 119

def shared_pinned_connection?
  false
end

#standard_privilege_statements(_ctx, grant_to:, include_functions: true) ⇒ Object

The statements Privileges.standard should execute for ctx.phase, or [] when this engine needs none in that phase. A pure function of its inputs: build, do not execute, so the SQL is unit-testable without a database.

ConfigurationError rather than NotImplementedError. An adopter who configured the standard policy on a strategy that has none made a configuration mistake, and NotImplementedError descends from ScriptError, so rescue StandardError around Tenant.create would not catch it. The NotImplementedError raises elsewhere in this class mean something different: a subclass owes an implementation.



165
166
167
168
169
# File 'lib/apartment/adapters/abstract_adapter.rb', line 165

def standard_privilege_statements(_ctx, grant_to:, include_functions: true) # rubocop:disable Lint/UnusedMethodArgument
  raise(Apartment::ConfigurationError,
        "Apartment::Privileges.standard does not support #{self.class.name}. " \
        'Write a tenant_privilege_policy for this strategy; see docs/rbac.md.')
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)


149
150
151
152
153
# File 'lib/apartment/adapters/abstract_adapter.rb', line 149

def tenant_container_gone?(error, tenant)
  return false unless container_error?(unwrap_db_error(error))

  !tenant_container_exists?(tenant)
end

#unqualified_pinned_message(model, name, prefix) ⇒ Object



342
343
344
345
346
347
348
349
# File 'lib/apartment/adapters/abstract_adapter.rb', line 342

def unqualified_pinned_message(model, name, prefix)
  "[Apartment] #{model.name || model.inspect} is pinned but its table name " \
    "(#{name.inspect}) is not qualified with #{prefix.inspect}, so it would read the " \
    'current tenant instead of the default tenant. This usually means Rails composed ' \
    "the name from something the qualifier cannot reach — a module parent's " \
    'table_name_prefix, or a base class outside the pinned hierarchy. Call pin_tenant ' \
    'on the model directly, or set self.table_name to an already-qualified name.'
end

#unregistered_pinned_subclass?(sub, registered) ⇒ Boolean

Returns:

  • (Boolean)


470
471
472
473
474
475
476
477
478
479
# File 'lib/apartment/adapters/abstract_adapter.rb', line 470

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

#verified_pinned_qualifierObject

A nil or empty qualifier produces a bare ".table", which start_with?(".") would happily accept — the check proving nothing in exactly the case it exists for. On MySQL this is a connection config with no 'database' key.



306
307
308
309
310
311
312
313
314
# File 'lib/apartment/adapters/abstract_adapter.rb', line 306

def verified_pinned_qualifier
  qualifier = pinned_table_qualifier
  return qualifier unless qualifier.nil? || qualifier.to_s.empty?

  raise(Apartment::ConfigurationError,
        "[Apartment] #{self.class}#pinned_table_qualifier is #{qualifier.inspect}, so pinned " \
        'models were qualified to a bare ".table" and would not resolve. On MySQL this ' \
        "usually means the connection config carries no 'database' key.")
end

#verify_pinned_qualification!(klass) ⇒ Object

Prove the qualification took effect rather than assuming it did.

A failed qualification is otherwise indistinguishable from a successful one: the model is marked processed, nothing raises, and it serves the current tenant's rows. Checking the post-condition surfaces that class of failure at the point it happens — including one introduced by a future Rails change to the naming internals, since compute_table_name is not public API.

An abstract base has no table of its own, so it is proven through the descendants its prefix was meant to reach; a nil name is skipped.

The descendant set is computed here rather than reused from the resync pass: that one is deliberately limited to descendants holding a memo, while a descendant with no memo inherits its name lazily and can be just as wrong. Descendants that declare their own table are excluded — an ancestor's qualification was never meant to reach them, and warn_unregistered_pinned_subclasses already reports that shape. The model itself RAISES; its descendants only WARN. The asymmetry is the point, and it is the same rule warn_unregistered_pinned_subclasses follows: raise only on what this pass can actually prove.

For the registered model, the check is complete and unambiguous — it was just qualified, so if the name is not qualified something is genuinely broken, every time, on every boot.

For descendants it is neither. Detection walks descendants, so it is complete under eager loading and partial under Zeitwerk lazy loading: raising would fail production boots for a condition that dev never reports, while still missing the descendants that load later. A warning carries the same signal without making detection completeness a boot-time dependency.



271
272
273
274
275
276
277
278
279
280
# File 'lib/apartment/adapters/abstract_adapter.rb', line 271

def verify_pinned_qualification!(klass)
  prefix = "#{verified_pinned_qualifier}."

  name = pinned_table_name_for(klass)
  if name && !name.start_with?(prefix)
    raise(Apartment::ConfigurationError, unqualified_pinned_message(klass, name, prefix))
  end

  warn_unqualified_descendants(klass, prefix)
end

#warn_unqualified_descendants(klass, prefix) ⇒ Object



282
283
284
285
286
287
288
289
# File 'lib/apartment/adapters/abstract_adapter.rb', line 282

def warn_unqualified_descendants(klass, prefix)
  inheriting_descendants(klass).each do |sub|
    sub_name = pinned_table_name_for(sub)
    next if sub_name.nil? || sub_name.start_with?(prefix)

    warn(unqualified_pinned_message(sub, sub_name, prefix))
  end
end

#warn_unqualified_subclass(klass, sub) ⇒ Object



481
482
483
484
485
486
# File 'lib/apartment/adapters/abstract_adapter.rb', line 481

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.



428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'lib/apartment/adapters/abstract_adapter.rb', line 428

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