Module: CurrentScope::ParentChain

Defined in:
lib/current_scope/parent_chain.rb

Overview

The ONE answer to "what are this record's declared ancestors?" (#108).

A model opts in with current_scope_parent :project. The resolver, failing a direct scoped-grant match, walks that chain and matches grants against the ancestors instead. Flat stays the default: a model that declares nothing gets an empty chain and a byte-identical decision.

WHY A MACRO, when every other host hook (current_scope_record, current_scope_model, current_scope_initiator, current_scope_sod_bypassed?) is a plain method: those answer a per-INSTANCE question, so returning a value is enough. This one must also produce a QUERYABLE key — Resolver#scope_for builds where(<foreign_key> => granted_ancestor_ids), and a method handing back a parent instance cannot yield a foreign key without loading every candidate row. Naming an association gives both derivations (the walk and the query) from one declaration, so the gate and the list cannot drift. The departure is deliberate and documented in the README rather than left to surprise a reader.

DECLARATION ERRORS RAISE. DATA NEVER DOES. That split is load-bearing and was learned the hard way in review: the first cut raised ConfigurationError when a record's chain was too deep or looped, which meant two UPDATEs on a parent_id column — no code change at all — could 500 a live request, escape report mode's "never breaks a request" promise, and print a fix ("remove one of the current_scope_parent declarations") pointing at code that was correct. So: a bad DECLARATION raises when the host writes it (at the macro for most shapes; from validate_declarations! after load for a custom association primary key, which needs reflection.klass); bad or over-deep DATA truncates the walk, denies (fail-closed), and warns once.

Defined Under Namespace

Modules: Declaration

Constant Summary collapse

MAX_PARENT_DEPTH =

A private ceiling, not a config knob: nobody can pick a default for a knob before a host declares a chain deep enough to need it. Raise it when one asks.

Truncating past it is FAIL-CLOSED — fewer ancestors means fewer grants match, so the answer is a denial, never an escalation. It is also the only option that keeps the gate and the list agreeing: Resolver#ancestor_scope_for walks CLASSES, and a legitimate self-referential chain (Project belongs_to :parent, class_name: "Project") never terminates at the class level however shallow the data is. A ceiling that raised on one side and truncated on the other is exactly the divergence this constant exists to prevent.

5

Class Method Summary collapse

Class Method Details

.ancestors_for(record) ⇒ Object

The ancestors a scoped grant may be matched against, nearest parent first, root last. Empty for an unopted model, a class, or an unsaved record — all three are "nothing to walk", not an error.



186
187
188
189
190
191
192
193
194
195
# File 'lib/current_scope/parent_chain.rb', line 186

def ancestors_for(record)
  return [] unless record.respond_to?(:new_record?) && record.persisted?

  unless declared?(record.class)
    reject_method_form!(record.class)
    return []
  end

  walk(record)
end

.declared?(klass) ⇒ Boolean

Whether this class (or an STI ancestor it inherits from) opted in. klass < ActiveRecord::Base rather than a bare respond_to? duck-type: the macro is installed on ActiveRecord::Base, so anything else answering this trio is a coincidence, and honouring it would let a non-AR class steer grant matching. Mirrors the record-less branch's collection_type? guard.

Returns:

  • (Boolean)


203
204
205
206
207
# File 'lib/current_scope/parent_chain.rb', line 203

def declared?(klass)
  klass.is_a?(Class) && klass < ActiveRecord::Base &&
    klass.respond_to?(:current_scope_parent_declared?) &&
    klass.current_scope_parent_declared?
end

.declared_namesObject



126
127
128
# File 'lib/current_scope/parent_chain.rb', line 126

def declared_names
  @declared_names ||= Set.new
end

.reflection_for(klass) ⇒ Object

The declared association reflection, or nil. scope_for reads this for the foreign key; it must never re-derive the name itself. Resolved from base_class, ALWAYS. The declaration is refused on an STI subclass, but a subclass can still OVERRIDE the association it inherits (a different class_name or foreign key). The per-record gate reads the instance's class and the collection query reads the base, so resolving per-class would let those two walk different associations for the same record — the drift the STI refusal exists to prevent, re-entering by the back door. One declared base reflection feeds both. (cubic P1)



218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/current_scope/parent_chain.rb', line 218

def reflection_for(klass)
  unless declared?(klass)
    # Both paths funnel through here, so this is where the method-form
    # mistake has to be caught. Checking it only in ancestors_for meant the
    # member gate raised while scope_for silently omitted parent grants —
    # the collection answering :no_grant with no diagnosis. (qodo 3)
    reject_method_form!(klass)
    return nil
  end

  base = klass.base_class
  base.reflect_on_association(base.current_scope_parent_association)
end

.register(klass) ⇒ Object

Declared classes, by NAME so dev-mode reloading never pins a stale constant (same reason CurrentScope::Scopeable stores strings).



122
123
124
# File 'lib/current_scope/parent_chain.rb', line 122

def register(klass)
  declared_names << klass.name if klass.name
end

.reset_warnings!Object

Reset between reloads/tests so a truncation warning is not latched by a run that has since been fixed (same reason the gating tripwire resets).



234
235
236
# File 'lib/current_scope/parent_chain.rb', line 234

def reset_warnings!
  @warned = nil
end

.validate_declarations!Object

Run once at boot (and on reload) rather than on the request path. validate_key! needs reflection.klass, which cannot resolve inside the macro without breaking an ordinary forward reference between two models that name each other — so the check has to happen later. Later must not mean "on the first gated request", because that is a deploy that boots green and 500s on real traffic. (cubic P2, ie-predictability P1)

A single call sees only the classes loaded so far — in EVERY environment, not just development. That is why the engine calls it twice; see below.

WORKS IN WAVES, and both halves of that are load-bearing.

It cannot iterate the Set directly: validate_key! resolves reflection.klass, which AUTOLOADS the parent model, and a parent that declares a chain of its own registers itself from its class body — mutating the very Set being iterated. Ruby answers that with "can't add a new key into hash during iteration", a RuntimeError out of to_prepare for any host whose declared chain points at another declaring model. The dummy's Report -> Project is exactly that shape.

It cannot iterate ONE snapshot either: a model that registers mid-pass would then wait for a later call of this method. Within one call there is no second chance, so it keeps taking snapshots until no new name appears — the walk is what loads those models, so it is also what must finish checking them. Terminates because the registry is finite and validated only grows. (#133 review — cubic)

CALLED TWICE, on purpose (#139). From to_prepare, which railties runs BEFORE :eager_load! ("This needs to happen before eager load so it happens in exactly the same point regardless of config.eager_load") — so that pass sees only what was already loaded, and earns its keep in development by re-running on every reload. And from after_initialize where eager loading is on, which is the authoritative pass: every declaring model that was eager-loaded (and thus registered) is validated. Models outside eager_load_paths or marked do_not_eager_load still register only when first loaded, and nothing on the request path re-checks them. Idempotent, so running twice costs a second walk over a set that raises on the first bad entry either way.



168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'lib/current_scope/parent_chain.rb', line 168

def validate_declarations!
  validated = Set.new

  while (pending = declared_names.to_a.reject { |name| validated.include?(name) }).any?
    pending.each do |name|
      validated << name
      klass = name.safe_constantize
      next if klass.nil?

      reflection = klass.reflect_on_association(klass.current_scope_parent_association)
      validate_key!(klass, reflection) if reflection
    end
  end
end