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
-
.ancestors_for(record) ⇒ Object
The ancestors a scoped grant may be matched against, nearest parent first, root last.
-
.declared?(klass) ⇒ Boolean
Whether this class (or an STI ancestor it inherits from) opted in.
- .declared_names ⇒ Object
-
.reflection_for(klass) ⇒ Object
The declared association reflection, or nil.
-
.register(klass) ⇒ Object
Declared classes, by NAME so dev-mode reloading never pins a stale constant (same reason CurrentScope::Scopeable stores strings).
-
.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).
-
.validate_declarations! ⇒ Object
Run once at boot (and on reload) rather than on the request path.
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.
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_names ⇒ Object
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 |