ros-apartment 4.0.0.alpha11 → 4.0.0.alpha12

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad60ba3db98c859ff52e1aa0f312d18a8a3e99a6b2703518a94f1ab4abc95eb5
4
- data.tar.gz: a782d5dc9690202676d2e30529da89aeb226961bffaf9af7fc821292266d97c6
3
+ metadata.gz: 2bb81b710aa16f388e8861ad84d358eee5ec751ff6cce1ad3121513e9a0ef46e
4
+ data.tar.gz: 49adf108763c408b893a314f223756697ccaf21b950087656b357aeb93f33dcd
5
5
  SHA512:
6
- metadata.gz: 18aaad1312c4eb812c1a7f4a1bcd676325a90db1326efcd2bb4aa71961f38e689240a589497ae6fa7bb16f362276649b36610d76b4b63284567d1999a4a20b5f
7
- data.tar.gz: 2f445f6e7d07ef0c4146242aadbb724636fa55184e4c16400daf5870e84a46a6566530cca0a10e130813709c27a08dfed1ba8b6eb4589ab97087b37966ce0b24
6
+ metadata.gz: 773ee7eff98a2f2287c53aef4b1b8172c97b2251c3d82e3f4b099e60b9602f7093cec92b8bdef9669d71a864bece4f767a2ba7dad9109816a80e8478b6087ed7
7
+ data.tar.gz: 1cf4884cd5b476e57481e8f928c39575e95cdc75249b86fa24dbfeb6885d28f9af1567795f22d1d71270cd402755711727f6614ecc566998d85a47cae780635c
@@ -97,9 +97,13 @@ All inherit from `AbstractAdapter`. Override `resolve_connection_config`, `creat
97
97
 
98
98
  **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.
99
99
 
100
- **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**.
100
+ **Table naming:** `apartment_explicit_table_name?` — whether the cached `@table_name` is one Rails' convention machinery would rebuild (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**. It selects a **restore** strategy only — it is *not* a qualification discriminator, and using it as one shipped three silent no-ops (see `docs/designs/v4-shared-pinned-connections.md`): `compute_table_name` honours `full_table_name_prefix` only on its `base_class?` branch, so prefix-based qualification is discarded outright for subclasses and for models whose module parent defines `table_name_prefix`. Qualification always assigns `table_name` directly.
101
101
 
102
- **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).
102
+ **Lifecycle:** `apartment_pinned_processed?`, `apartment_mark_processed!`, `apartment_restore!` — qualification state and teardown. Paths are `:computed` (convention rebuilds the name; restore drops the `@table_name` override and recomputes), `:explicit` (restore assigns the saved name back verbatim), `:prefix` (abstract base; restore puts back the app's `table_name_prefix`), and `nil` (separate-pool; nothing to undo). **Abstract bases are the one case still qualified via `table_name_prefix`**, because `pin_tenant` early-returns once a superclass is pinned — so concrete descendants are never registered and only a `class_attribute` broadcast reaches them. 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).
103
+
104
+ **Subclasses of a pinned model:** `pin_tenant` is idempotent **per class**, not per hierarchy — it keys on the class's own flag, not `apartment_pinned?` (which walks the superclass chain). A subclass declaring its own table must register and qualify on its own merits, since the parent's qualification cannot reach a different table; keying on the chain made that call silently no-op. A subclass that *shares* the parent's table still needs nothing and is skipped at qualification time by `AbstractAdapter#inherits_pinned_table?`. A subclass that declares its own table and is never registered gets a boot warning (`warn_unregistered_pinned_subclasses`, descendants-based, so complete only under eager loading). See `docs/designs/v4-shared-pinned-connections.md`.
105
+
106
+ **Descendant memos:** Rails memoizes `@table_name` per class and never invalidates a descendant's copy when an ancestor changes, so an early read (initializer, gem, route constraint) would freeze the *unqualified* name and the pinned model would read the tenant's table forever. `qualify_pinned_table_name` and `apartment_restore!` bracket their mutation with `apartment_descendants_inheriting_table_name` (collected **before**, while a stale memo is still distinguishable from a declaration) and `apartment_resync_descendant_table_names!` (`reset_table_name` after, which clears `@quoted_table_name`/`@arel_table` via Rails' own setter).
103
107
 
104
108
  **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.
105
109
 
@@ -148,12 +148,96 @@ module Apartment
148
148
  !tenant_container_exists?(tenant)
149
149
  end
150
150
 
151
- # Qualify a pinned model's table_name so it targets the default
152
- # tenant's tables from any tenant connection. Subclasses must
153
- # implement when shared_pinned_connection? returns true.
154
- def qualify_pinned_table_name(_klass)
151
+ # The namespace that makes the default tenant's tables reachable from any
152
+ # tenant connection a schema (PostgreSQL) or a database (MySQL).
153
+ # Subclasses must implement when shared_pinned_connection? returns true.
154
+ def pinned_table_qualifier
155
155
  raise(NotImplementedError,
156
- "#{self.class}#qualify_pinned_table_name must be implemented when shared_pinned_connection? is true")
156
+ "#{self.class}#pinned_table_qualifier must be implemented when shared_pinned_connection? is true")
157
+ end
158
+
159
+ # Qualify a pinned model's table_name so it targets the default tenant's
160
+ # tables from any tenant connection.
161
+ #
162
+ # Always assigns table_name directly. The tempting alternative — set
163
+ # table_name_prefix and let Rails recompose — is unsound, because
164
+ # compute_table_name only consults full_table_name_prefix on its
165
+ # base_class? branch:
166
+ #
167
+ # * a class that is not its own base_class gets base_class.table_name
168
+ # verbatim, so the prefix is discarded outright;
169
+ # * full_table_name_prefix prefers the first module parent that responds
170
+ # to table_name_prefix, so an engine-namespaced model ignores the
171
+ # prefix set on the class itself;
172
+ # * overwriting the prefix drops one the app set, silently retargeting
173
+ # the model at a different table.
174
+ #
175
+ # Each case left the model resolving to the *tenant's* table with no
176
+ # error. Reading table_name first lets Rails compute the conventional
177
+ # name — honouring any prefix, suffix, or nesting the app declared —
178
+ # before we qualify the result.
179
+ def qualify_pinned_table_name(klass)
180
+ # Captured before the mutation below: afterwards there is no way to
181
+ # tell a descendant's stale memo from a table it declared itself.
182
+ inheriting = klass.apartment_descendants_inheriting_table_name
183
+ apply_pinned_qualification(klass)
184
+ klass.apartment_resync_descendant_table_names!(inheriting)
185
+ end
186
+
187
+ def apply_pinned_qualification(klass)
188
+ return qualify_pinned_table_name_prefix(klass) if klass.abstract_class?
189
+ return klass.apartment_mark_processed! if inherits_pinned_table?(klass)
190
+
191
+ # Captured before the assignment below, which would otherwise make
192
+ # every model look explicitly named.
193
+ path = klass.apartment_explicit_table_name? ? :explicit : :computed
194
+ original = klass.table_name
195
+ klass.table_name = "#{pinned_table_qualifier}.#{original.sub(/\A[^.]+\./, '')}"
196
+ klass.apartment_mark_processed!(path, (original if path == :explicit))
197
+ end
198
+
199
+ # An abstract class has no table of its own — table_name is nil — so
200
+ # there is nothing to assign. Pinning one is a supported pattern (an
201
+ # abstract `connects_to` base is pinned so Apartment does not build
202
+ # tenant pools for it), and its qualifier still has to reach the concrete
203
+ # descendants that inherit the pin.
204
+ #
205
+ # Those descendants are never qualified directly: pin_tenant early-returns
206
+ # once any superclass is pinned (apartment_pinned? walks the chain), so
207
+ # they are never registered and process_pinned_models never sees them.
208
+ # table_name_prefix is a class_attribute, so setting it here broadcasts
209
+ # down the inheritance chain and each descendant composes it in its own
210
+ # compute_table_name. Any prefix the app set is preserved rather than
211
+ # overwritten, so `myapp_` becomes `<qualifier>.myapp_`.
212
+ #
213
+ # This is the one place the prefix mechanism is still correct, because
214
+ # here it is a broadcast to other classes rather than an attempt to
215
+ # qualify this class's own name.
216
+ def qualify_pinned_table_name_prefix(klass)
217
+ original_prefix = klass.table_name_prefix
218
+ klass.table_name_prefix = "#{pinned_table_qualifier}.#{original_prefix}"
219
+ klass.apartment_mark_processed!(:prefix, original_prefix)
220
+ end
221
+
222
+ # Whether +klass+ reaches its table through an already-pinned base class
223
+ # and so needs no qualification of its own. Rails resolves a subclass's
224
+ # table through base_class.table_name, which the base's qualification
225
+ # already covers; assigning here would freeze a copy of the base's
226
+ # qualified name onto the child and desynchronise the two on teardown.
227
+ #
228
+ # Scoped narrowly on purpose. A subclass that declares its own table —
229
+ # the transitional shape when migrating an STI child off a pinned
230
+ # parent's table — is NOT covered and qualifies normally, because the
231
+ # parent's qualification cannot reach a different table. And a subclass
232
+ # whose base class is not pinned (e.g. an app model extending a gem's
233
+ # model) is not covered either, which is the case that motivated
234
+ # qualifying by assignment in the first place.
235
+ def inherits_pinned_table?(klass)
236
+ return false if klass.base_class?
237
+ return false if klass.apartment_explicit_table_name?
238
+
239
+ base = klass.base_class
240
+ base.respond_to?(:apartment_pinned?) && base.apartment_pinned?
157
241
  end
158
242
 
159
243
  # Process all pinned models. When shared_pinned_connection? is true, qualifies
@@ -167,6 +251,86 @@ module Apartment
167
251
  raise(Apartment::ConfigurationError,
168
252
  "Failed to process pinned model #{klass.name}: #{e.class}: #{e.message}")
169
253
  end
254
+
255
+ warn_unregistered_pinned_subclasses
256
+ end
257
+
258
+ # Warn about subclasses of a pinned model that declare their own table and
259
+ # were never registered. Such a class inherits apartment_pinned? through
260
+ # the superclass walk but gets no qualification, so on a shared-connection
261
+ # adapter it silently reads the *tenant's* table — and on a separate-pool
262
+ # adapter a genuinely tenant-scoped one silently reads the default's. The
263
+ # shape is transitional (migrating an STI child off a pinned parent's
264
+ # table), which is exactly when a silent read is most costly: the symptom
265
+ # looks like a botched backfill.
266
+ #
267
+ # Detection walks descendants, so it is complete under eager loading
268
+ # (production boot, CI) and partial under Zeitwerk lazy loading. That is
269
+ # tolerable for a warning and would not be for a raise — which is why this
270
+ # warns rather than raising.
271
+ # descendants is transitive, so every pinned class in one inheritance
272
+ # chain sees the same unregistered descendant. Deduplicate, and attribute
273
+ # each warning to the *nearest* pinned ancestor — the one whose pin the
274
+ # subclass actually inherits — so the message is deterministic rather than
275
+ # dependent on registry iteration order.
276
+ def warn_unregistered_pinned_subclasses
277
+ # Snapshot the registry and walk it outside its own lock. Concurrent::Set
278
+ # synchronizes every method on CRuby, #each included, so iterating in
279
+ # place would hold a process-wide monitor across descendant walking and
280
+ # stderr I/O. Same leaf-lock discipline as Patches::ConnectionRegistry.
281
+ pinned = Apartment.pinned_models.to_a
282
+ registered = Set.new(pinned)
283
+ seen = Set.new
284
+
285
+ pinned.each do |klass|
286
+ next unless klass.respond_to?(:descendants)
287
+
288
+ klass.descendants.each do |sub|
289
+ check_pinned_subclass(klass, sub, registered) if seen.add?(sub)
290
+ end
291
+ end
292
+ end
293
+
294
+ # Advisory only: this runs from process_pinned_models, which Tenant.init
295
+ # calls in after_initialize, so it must never be able to fail a boot.
296
+ # Rails' naming machinery raises on shapes we do not control — an
297
+ # anonymous descendant has no model_name, and Class.new(SomeBase) is
298
+ # everywhere in test suites.
299
+ def check_pinned_subclass(klass, sub, registered)
300
+ return unless unregistered_pinned_subclass?(sub, registered)
301
+
302
+ warn_unqualified_subclass(nearest_pinned_ancestor(sub, registered) || klass, sub)
303
+ rescue StandardError => e
304
+ warn "[Apartment] could not check pinned subclass #{sub.inspect}: #{e.class}: #{e.message}"
305
+ end
306
+
307
+ # The closest registered ancestor above +klass+, i.e. the pin it inherits.
308
+ def nearest_pinned_ancestor(klass, registered)
309
+ ancestor = klass.superclass
310
+ while ancestor.is_a?(Class) && ancestor < ActiveRecord::Base
311
+ return ancestor if registered.include?(ancestor)
312
+
313
+ ancestor = ancestor.superclass
314
+ end
315
+ nil
316
+ end
317
+
318
+ def unregistered_pinned_subclass?(sub, registered)
319
+ return false if registered.include?(sub)
320
+ # Anonymous classes have no model_name for Rails to compute a table
321
+ # from, and nothing actionable to name in a warning.
322
+ return false if sub.name.nil?
323
+ return false unless sub.respond_to?(:apartment_explicit_table_name?)
324
+ return false if sub.abstract_class?
325
+
326
+ sub.apartment_explicit_table_name?
327
+ end
328
+
329
+ def warn_unqualified_subclass(klass, sub)
330
+ warn "[Apartment] #{sub.name || sub.inspect} inherits a pin from " \
331
+ "#{klass.name || klass.inspect} but declares its own table " \
332
+ "(#{sub.table_name}) and was never registered, so it is not qualified. " \
333
+ "Call pin_tenant on it if it should read the default tenant's data."
170
334
  end
171
335
 
172
336
  # Process a single pinned model. Called by process_pinned_models (batch)
@@ -187,6 +351,14 @@ module Apartment
187
351
 
188
352
  return if klass.apartment_pinned_processed?
189
353
 
354
+ # A subclass that reaches its table through an already-pinned base needs
355
+ # nothing on either path. Qualifying would freeze a copy of the base's
356
+ # name onto it; establishing a connection would hand it a *different*
357
+ # pool from its parent, splitting two classes that share one physical
358
+ # table across connections and breaking transactional integrity between
359
+ # them. Mark processed with a nil path so teardown skips it too.
360
+ return klass.apartment_mark_processed! if inherits_pinned_table?(klass)
361
+
190
362
  if shared_pinned_connection?
191
363
  qualify_pinned_table_name(klass)
192
364
  else
@@ -14,20 +14,10 @@ module Apartment
14
14
  !Apartment.config.force_separate_pinned_pool
15
15
  end
16
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
17
+ # Pinned tables live in the default tenant's database; every tenant
18
+ # connection can reach them by database-qualifying the name.
19
+ def pinned_table_qualifier
20
+ base_config['database']
31
21
  end
32
22
 
33
23
  def resolve_connection_config(tenant, base_config: nil)
@@ -18,18 +18,10 @@ module Apartment
18
18
  !Apartment.config.force_separate_pinned_pool
19
19
  end
20
20
 
21
- def qualify_pinned_table_name(klass)
22
- if klass.apartment_explicit_table_name?
23
- original = klass.table_name
24
- table = original.sub(/\A[^.]+\./, '')
25
- klass.table_name = "#{default_tenant}.#{table}"
26
- klass.apartment_mark_processed!(:explicit, original)
27
- else
28
- original_prefix = klass.table_name_prefix
29
- klass.table_name_prefix = "#{default_tenant}."
30
- klass.reset_table_name
31
- klass.apartment_mark_processed!(:convention, original_prefix)
32
- end
21
+ # Pinned tables live in the default tenant's schema; every tenant
22
+ # connection can reach them by schema-qualifying the name.
23
+ def pinned_table_qualifier
24
+ default_tenant
33
25
  end
34
26
 
35
27
  def resolve_connection_config(tenant, base_config: nil)
@@ -16,12 +16,23 @@ module Apartment
16
16
  # their connection always targets the default tenant's database/schema.
17
17
  #
18
18
  # Safe to call before or after Apartment.activate!.
19
- # Idempotent: no-op if this class (or a parent) is already pinned.
19
+ # Idempotent per class: a second call on the same class is a no-op.
20
+ #
21
+ # Deliberately keyed on this class's own flag, NOT on apartment_pinned?
22
+ # (which walks the superclass chain). A subclass of a pinned model that
23
+ # declares its own table has to be registered and qualified on its own
24
+ # merits — the parent's qualification cannot reach a different table.
25
+ # Keying on the chain made that call accept-and-do-nothing, which is the
26
+ # wrong answer even for a shape apps should avoid: an API call must
27
+ # either work or be absent, never silently no-op. Subclasses that share
28
+ # the parent's table still need nothing, and are skipped at
29
+ # qualification time rather than here (see
30
+ # AbstractAdapter#inherits_pinned_table?).
20
31
  def pin_tenant
21
32
  unless is_a?(Class) && self < ActiveRecord::Base
22
33
  raise(ArgumentError, "pin_tenant can only be called on ActiveRecord model classes, got #{inspect}")
23
34
  end
24
- return if apartment_pinned?
35
+ return if @apartment_pinned
25
36
 
26
37
  @apartment_pinned = true
27
38
  Apartment.register_pinned_model(self)
@@ -54,10 +65,21 @@ module Apartment
54
65
  superclass.apartment_pinned?
55
66
  end
56
67
 
57
- # Whether this model has an explicit self.table_name = assignment
58
- # (as opposed to Rails' lazy convention computation). Returns false
59
- # if the explicit value matches what convention would produce, since
60
- # the convention path handles that case correctly.
68
+ # Whether this model's current table_name would survive a recomputation
69
+ # i.e. whether Rails' convention machinery reproduces the value now
70
+ # cached in @table_name. False means convention rebuilds it exactly, so
71
+ # qualification can be undone by discarding the override; true means the
72
+ # name must be saved verbatim and restored verbatim.
73
+ #
74
+ # This answers a *restore* question. It is deliberately NOT a
75
+ # qualification discriminator: a table_name that matches convention is no
76
+ # evidence that setting table_name_prefix would qualify the model. Rails
77
+ # ignores the prefix outright for any class that is not its own
78
+ # base_class (compute_table_name returns base_class.table_name verbatim),
79
+ # and full_table_name_prefix prefers a module parent's prefix over the
80
+ # class's own. Qualification therefore always assigns table_name
81
+ # directly — see AbstractAdapter#qualify_pinned_table_name.
82
+ #
61
83
  # NOTE: compute_table_name is a private Rails API; tested against
62
84
  # Rails main as a canary in CI.
63
85
  def apartment_explicit_table_name?
@@ -66,40 +88,110 @@ module Apartment
66
88
  instance_variable_get(:@table_name) != send(:compute_table_name)
67
89
  end
68
90
 
91
+ # Descendants that reach their table name through this class rather than
92
+ # declaring one of their own, and have already memoized it.
93
+ #
94
+ # Rails memoizes @table_name per class on first read and never
95
+ # invalidates a descendant's copy when an ancestor's name or prefix
96
+ # changes. Anything touching a descendant's table_name before
97
+ # qualification runs — an initializer, a gem, a route constraint, a
98
+ # descendants sweep — freezes the pre-qualification name, and the model
99
+ # then resolves to the wrong tenant's table for the life of the process.
100
+ #
101
+ # MUST be called BEFORE the ancestor is mutated: afterwards
102
+ # apartment_explicit_table_name? can no longer distinguish a stale memo
103
+ # from a genuine declaration, since the ancestor's change has moved what
104
+ # convention computes. Descendants without a memo are omitted — they
105
+ # compute lazily and will pick the new value up on their own.
106
+ def apartment_descendants_inheriting_table_name
107
+ return [] unless respond_to?(:descendants)
108
+
109
+ stale = descendants.select do |sub|
110
+ sub.instance_variable_defined?(:@table_name) &&
111
+ sub.respond_to?(:apartment_inherited_table_name) &&
112
+ sub.instance_variable_get(:@table_name) == sub.apartment_inherited_table_name
113
+ rescue StandardError
114
+ # Rails' naming machinery raises on shapes we do not control (an
115
+ # anonymous class has no model_name). Skip rather than guess.
116
+ false
117
+ end
118
+
119
+ # Reset an intermediate before anything beneath it: reset_table_name on
120
+ # a class under an abstract parent reads superclass.table_name — the
121
+ # memo, not base_class — so a grandchild reset first would re-freeze its
122
+ # parent's stale value. ActiveSupport's descendants happens to be
123
+ # ancestor-first today, but that ordering is undocumented.
124
+ stale.sort_by { |sub| sub.ancestors.size }
125
+ end
126
+
127
+ # What Rails' own reset_table_name would recompute for this class right
128
+ # now. Mirrors ActiveRecord::ModelSchema#reset_table_name deliberately.
129
+ #
130
+ # NOT compute_table_name: the two disagree for a class whose superclass
131
+ # is abstract. reset_table_name prefers `superclass.table_name`, while
132
+ # compute_table_name treats such a class as its own base_class and builds
133
+ # from its own model_name. So a concrete class under an abstract
134
+ # intermediate that carries a table (an abstract class sandwiched under a
135
+ # concrete pinned parent) inherits `foos` but computes `gkids` — and
136
+ # keying on compute_table_name misreads that inherited name as an
137
+ # explicit declaration, skipping the resync and leaving the model on the
138
+ # tenant's table.
139
+ def apartment_inherited_table_name
140
+ if abstract_class?
141
+ superclass.table_name
142
+ elsif superclass.abstract_class?
143
+ superclass.table_name || send(:compute_table_name)
144
+ else
145
+ send(:compute_table_name)
146
+ end
147
+ end
148
+
149
+ # Recompute the table name of each descendant captured above, now that
150
+ # this class has changed. reset_table_name goes through Rails' own
151
+ # table_name= setter, so the derived @quoted_table_name and @arel_table
152
+ # caches are cleared with it.
153
+ def apartment_resync_descendant_table_names!(subclasses)
154
+ subclasses.each do |sub|
155
+ sub.reset_table_name
156
+ rescue StandardError => e
157
+ warn "[Apartment] could not reset table name for #{sub.name || sub.inspect}: " \
158
+ "#{e.class}: #{e.message}"
159
+ end
160
+ end
161
+
69
162
  # Whether process_pinned_model has already run for this class.
70
163
  def apartment_pinned_processed?
71
164
  @apartment_pinned_processed == true
72
165
  end
73
166
 
74
- # Record that qualification has been applied, and what path was used.
167
+ # Record that qualification has been applied, and how it must be undone.
75
168
  # Called by qualify_pinned_table_name (adapters) after mutations succeed,
76
169
  # or by process_pinned_model after establish_connection on separate-pool path.
170
+ #
171
+ # :computed — the pre-qualification name was reproducible by convention;
172
+ # restore by discarding the override and recomputing.
173
+ # :explicit — the name was assigned in a way convention cannot rebuild;
174
+ # +original_value+ is that name, restored verbatim.
175
+ # :prefix — an abstract base, qualified by broadcasting through
176
+ # table_name_prefix to the descendants that inherit its pin;
177
+ # +original_value+ is the prefix the app had set.
178
+ # nil — separate-pool models; nothing to undo.
77
179
  def apartment_mark_processed!(path = nil, original_value = nil)
78
180
  @apartment_pinned_processed = true
79
181
  @apartment_qualification_path = path
80
182
  case path
81
183
  when :explicit then @apartment_original_table_name = original_value
82
- when :convention then @apartment_original_table_name_prefix = original_value
184
+ when :prefix then @apartment_original_table_name_prefix = original_value
83
185
  end
84
186
  end
85
187
 
86
188
  # Undo table name qualification and clear tracking state.
87
- # Convention path: restore original prefix so reset_table_name recomputes.
88
- # Explicit path: restore the original table_name that was overwritten.
89
- # nil path: separate-pool models — no table name changes to undo.
90
189
  def apartment_restore!
91
190
  return unless @apartment_pinned_processed
92
191
 
93
- case @apartment_qualification_path
94
- when :convention
95
- self.table_name_prefix = @apartment_original_table_name_prefix || ''
96
- reset_table_name
97
- when :explicit
98
- self.table_name = @apartment_original_table_name if @apartment_original_table_name
99
- when nil then nil
100
- else
101
- warn "[Apartment] #{name}: unexpected qualification_path #{@apartment_qualification_path.inspect}"
102
- end
192
+ inheriting = apartment_descendants_inheriting_table_name
193
+ apartment_undo_qualification!
194
+ apartment_resync_descendant_table_names!(inheriting)
103
195
 
104
196
  @apartment_pinned_processed = nil
105
197
  @apartment_qualification_path = nil
@@ -109,6 +201,31 @@ module Apartment
109
201
 
110
202
  private
111
203
 
204
+ # Reverse whichever mutation qualify_pinned_table_name applied.
205
+ def apartment_undo_qualification!
206
+ case @apartment_qualification_path
207
+ when :computed then apartment_recompute_table_name!
208
+ when :explicit then apartment_restore_table_name!
209
+ when :prefix then self.table_name_prefix = @apartment_original_table_name_prefix || ''
210
+ when nil then nil
211
+ else
212
+ warn "[Apartment] #{name}: unexpected qualification_path #{@apartment_qualification_path.inspect}"
213
+ end
214
+ end
215
+
216
+ # Drop the qualified override and recompute from convention. The ivar is
217
+ # removed first so nothing can keep the qualified value; the recompute
218
+ # then runs through Rails' table_name= setter, which also clears the
219
+ # derived @quoted_table_name and @arel_table caches.
220
+ def apartment_recompute_table_name!
221
+ remove_instance_variable(:@table_name) if instance_variable_defined?(:@table_name)
222
+ reset_table_name
223
+ end
224
+
225
+ def apartment_restore_table_name!
226
+ self.table_name = @apartment_original_table_name if @apartment_original_table_name
227
+ end
228
+
112
229
  # Register a one-shot TracePoint(:end) that fires after the class body
113
230
  # closes. Only :end is used — :b_return fires for ALL block returns in
114
231
  # class context (each, tap, include hooks) and would trigger prematurely.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apartment
4
- VERSION = '4.0.0.alpha11'
4
+ VERSION = '4.0.0.alpha12'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ros-apartment
3
3
  version: !ruby/object:Gem::Version
4
- version: 4.0.0.alpha11
4
+ version: 4.0.0.alpha12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ryan Brunner