Module: Axn::Internal::ShapeGraph
- Defined in:
- lib/axn/internal/shape_graph.rb
Overview
Non-dispatching reads of a caller-supplied shape graph, and the copy every caller-supplied option container a declaration stores is taken through.
A shape: kwarg may be handed in raw — an arbitrary object as the shape Hash, and arbitrary
objects as its members — and axn has to decide what such a graph DECLARES before it can store
anything. An object that lies about its type, or about which methods it has, would otherwise make
a guard skip a member the declaration goes on to keep: the guard's verdict and the stored contract
disagree, which is precisely what the guard exists to prevent. So every question asked here is
answered from the real class and the real method table, never from a method the graph's own
objects can define.
What a walk genuinely REQUIRES stays the graph's own: reading :members off a Hash calls its
[], and reading a member's field invokes that reader. Those are the reads the declaration is
not knowable without, so a lie there changes what axn stores rather than splitting a guard from a
consumer — a [] that hides members declares a contract without them. Every read of a shape's
members in axn therefore goes through members below, and nothing reads them another way; a second
route (an each-copy of the shape's real entries) would see members [] had hidden and store a
list no guard had walked.
An INCONSISTENT lie — a [] or a reader answering differently on successive reads — does not split
them either, because the declaration walk reads each of them exactly ONCE and stores the answer
(Contract#_check_and_copy_shape_members! snapshots every member into a ShapeConfig of axn's
own). What a caller's object can decide is what the contract SAYS, at the one moment it is asked;
it cannot say one thing to a guard and another to a consumer, because no consumer asks it again.
That holds for what the walk CONVERTS as well as what it reads, and it has to be arranged as
deliberately: a member's name is canonicalized with to_sym, which is a second dispatch on the same
object, so the walk computes that Symbol once beside the check that judges it and stores THAT
(see _check_and_copy_shape_members!). Reading once while converting twice is the same defect wearing
a disguise — it split the duplicate check from the property the member was stored under.
Constant Summary collapse
- MAX_NESTING =
How deep a shape graph may nest, for every walk of one. Deep enough that no shape anyone writes by hand can reach it — a hand-written block nests one level per
do…end, and a schema nested 64 objects deep is unreadable long before it is undeclarable — so the cap only ever fires on a graph something GENERATED.It lives HERE, with the seam every layer reads a shape graph through, because it is the answer to the half of untraversability that identity cannot see.
CycleGuardcatches a graph that repeats an object; a graph that MINTS a fresh nested shape on every read repeats nothing, is endless rather than cyclic, and only a depth bound stops it. Every walk needs both, and two 64s in two layers would be free to drift into disagreeing about what is declarable. 64- MAX_MEMBER_PATHS =
How many member PATHS a stored shape graph may have — every route from a field to a member, counting a nested shape reused by two siblings twice, because every walk of the stored graph walks it twice.
Not a bound on emitted JSON properties: that one lives with reflection, is derived from what the emitter actually emits, and applies at projection. This is a bound on the graph ITSELF, and what it bounds is the cost of WALKING one, since every walk pays a step per path.
The walks that read a stored graph on a live CALL are what needs bounding, and each needs it for its own reason. Runtime shape validation walks the graph per call and can never be memoized — it is matching a VALUE against members, not deriving a constant. Redaction derives what it can from the declaration once per class (
Contract#_contract_redaction), so an ordinary contract pays for the graph one time — but that one time lands inside a side channel on a real call: measured with the bound removed, 786,000 paths (one nested shape shared by two siblings, 18 levels deep) cost about two seconds there. And asensitive:that resolves against the ACTION cannot be derived once at all, so it re-walks per logged call: about 1.3 seconds per log line at that same size.So the number is what a call may be asked to walk, and 25,000 is generous for anything hand-written: it only fires on a graph that MULTIPLIES out, where N levels of two-way sharing are 2^N paths and one extra nesting level is the whole distance from legal to rejected (13 levels charge 24,574 paths; 14 charge 49,150).
25_000
Class Method Summary collapse
-
.bound_method(object, name) ⇒ Object
The value of
nameonobject, or NOT_DEFINED when nothing answers to it. -
.capture(list) ⇒ Object
A caller-supplied list, captured into an Array this module owns.
-
.copy_entries(hash) ⇒ Object
A caller Hash's entries, in a plain Hash this module owns.
-
.declared_members(shape) ⇒ Object
The members list AS SUPPLIED: the captured list, or nil when the shape supplies none at all.
- .describe_via(member) ⇒ Object
-
.detach_node(hash) ⇒ Object
The same node with its members carried forward untouched — the caller's own list, which the declaration walk captures (exactly once) when it reaches this node.
-
.detach_option_containers!(validations) ⇒ Object
A contract must not change after the class is declared, and an option value the caller still holds is aliased into it: mutating a
validate:bag swapped the validator a declared field runs, a mutatedof:bag changed a declared element type, and appending to aninclusion:list widened a declared enum — each after the fact, on a class already defined. -
.detached_dup(value) ⇒ Object
A same-CLASS shallow copy.
-
.each_entry(hash) ⇒ Object
Every entry of a caller Hash — THE seam for reading one, so no layer ever asks a Hash subclass to traverse itself.
- .fetch(object, name) ⇒ Object
-
.hash_or_nil(value) ⇒ Object
valuewhen it is genuinely a Hash, else nil. -
.members(shape) ⇒ Object
A shape's members, captured into an Array this module owns.
- .missing?(value) ⇒ Boolean
-
.nested_shape(owner) ⇒ Object
The nested shape a config or member carries (
validations[:shape]), or nil when it carries none — so a member too minimal to declarevalidationsis skipped rather than raising. -
.read(object, name) ⇒ Object
The value of
nameonobject, or nil when nothing answers to it — for a caller that treats an absent reader and a nil one alike (a truthiness test, or a value that gets type-tested anyway). -
.reject_defaulting_option_container!(hash) ⇒ Object
A caller Hash that answers missing keys from a DEFAULT is refused wherever a declaration would store one.
- .self_containing_message(member) ⇒ Object
-
.shape_in(validations) ⇒ Object
The shape carried by an already-read validations Hash.
-
.snapshot_node(hash, members) ⇒ Object
ONE node of a caller-supplied shape graph, copied into a Hash this module owns, with
members— the copies the declaration walk built for this node — put in place of whatever it carried. -
.supplies_default?(hash) ⇒ Boolean
Whether a caller Hash answers a key it has no ENTRY for —
Hash.new(x)or adefault_proc. - .too_deep_message(member) ⇒ Object
Class Method Details
.bound_method(object, name) ⇒ Object
The value of name on object, or NOT_DEFINED when nothing answers to it.
Two lookups, because the standard of correctness is agreement with what reflection reads. The
first is Object#method, which finds a DEFINED method whatever respond_to? claims — so an
object defining a reader cannot opt out of a guard by denying it. The second is the plain
dispatch reflection itself makes, reached only when nothing defines the name: Object#method
falls back to respond_to_missing?, so a member served entirely by method_missing WITHOUT a
matching respond_to_missing? looks absent to the first lookup while member.field answers
reflection perfectly well — and a guard that skipped it would leave reflection emitting a name
nothing checked. Both are bound rather than dispatched (#method, #public_send and
#respond_to? are all overridable), so an object whose own version raises cannot replace a
declaration verdict with its exception.
Only "nothing answered to THIS name" counts as absence — a NoMethodError naming something else is a bug inside the reader and propagates. So an object that genuinely defines nothing is still skipped rather than raising: the distinction drawn is that a LIE cannot bypass a guard, not that a member must be a full ShapeConfig.
Neither half of that comparison dispatches anything the exception's class can define. The stored name
is extracted through NameError's own name (see NAME_ERROR_NAME), never the subclass's, and axn's
own Symbol is the receiver of equal? — so a subclass overriding name to raise, or returning an
object whose == raises, changes nothing here. Putting axn's Symbol on the left alone would not be
enough: reading e.name is itself the dispatch.
The Method the real method table defines for name, or nil when nothing does. Asked without dispatching
respond_to?, which an object can override to deny a method it has.
457 458 459 460 461 |
# File 'lib/axn/internal/shape_graph.rb', line 457 def self.bound_method(object, name) OBJECT_METHOD.bind_call(object, name) rescue ::NameError nil end |
.capture(list) ⇒ Object
A caller-supplied list, captured into an Array this module owns. THE seam every layer reads a member
list through — the declaration guard, schema reflection, and runtime validation all consume this, so
they cannot see different members. A list that answers filter_map/map/select differently from
each would otherwise give them two answers: reflection emitted nothing while the guard and the
validator saw two members.
365 366 367 368 369 370 371 |
# File 'lib/axn/internal/shape_graph.rb', line 365 def self.capture(list) return [] if nil.equal?(list) captured = [] list.each { |element| captured << element } # rubocop:disable Style/MapIntoArray -- `map` is overridable; `each` only captured end |
.copy_entries(hash) ⇒ Object
A caller Hash's entries, in a plain Hash this module owns.
94 95 96 97 98 |
# File 'lib/axn/internal/shape_graph.rb', line 94 def self.copy_entries(hash) copy = {} each_entry(hash) { |key, value| copy[key] = value } copy end |
.declared_members(shape) ⇒ Object
The members list AS SUPPLIED: the captured list, or nil when the shape supplies none at all. The
distinction matters in exactly one place — the declaration walk, where a raw shape with no members list
is malformed while an empty one is a real (if pointless) declaration. Every layer after that wants
members, which treats them alike because neither yields anything to walk. Nil for a non-Hash too: it
supplies no members either.
350 351 352 353 354 355 356 357 358 |
# File 'lib/axn/internal/shape_graph.rb', line 350 def self.declared_members(shape) hash = hash_or_nil(shape) return nil if nil.equal?(hash) raw = hash[:members] return nil if nil.equal?(raw) capture(raw) end |
.describe_via(member) ⇒ Object
287 288 289 290 291 |
# File 'lib/axn/internal/shape_graph.rb', line 287 def self.describe_via(member) return "" if nil.equal?(member) " reached from the shape member of class #{Axn::Internal::Reflection::PropertyNames.renderable_class_name(member)}" end |
.detach_node(hash) ⇒ Object
The same node with its members carried forward untouched — the caller's own list, which the declaration
walk captures (exactly once) when it reaches this node. For the one WRITE the declaration path makes
into a shape, deriving an absent :container: writing that into the caller's Hash would change a shape
they still hold, and a shape shared between two declarations would carry the first one's container into
the second.
407 |
# File 'lib/axn/internal/shape_graph.rb', line 407 def self.detach_node(hash) = snapshot_node(hash, hash[:members]) |
.detach_option_containers!(validations) ⇒ Object
A contract must not change after the class is declared, and an option value the caller still holds is
aliased into it: mutating a validate: bag swapped the validator a declared field runs, a mutated of:
bag changed a declared element type, and appending to an inclusion: list widened a declared enum —
each after the fact, on a class already defined.
Detached one level deep, which is exactly the boundary that matters: the plain Hash/Array CONTAINERS
axn stores are copied, while the values inside them stay the caller's objects — a validate: callable,
a model: class, an inclusion: member. Those are meant to be the caller's, and copying them would
change what a declaration means rather than protect it. shape: is excluded because it needs a deep
copy of its own (see Contract::ShapeDeclaration#_validate_and_snapshot_shape!) and gets one downstream.
Nothing an option container can define decides whether it is detached, or what the detached copy holds.
The type tests are case/when (Module#===, a C-level check) rather than is_a?, and the copies are
taken through bound primitives (see ShapeGraph) rather than the container's own transform_values/dup.
A subclass answering is_a?(Array) with false, or whose dup returned self, or whose
transform_values handed back the receiver, otherwise stayed aliased into the declared contract while
the plain-Array case beside it was correctly copied. An ARRAY that owns any of those is now refused
before the copy is attempted (see detached_option_array), so the bound dup is belt-and-braces there;
a BAG is not, since it is copied entry-wise whatever its class, which is what the bound Hash#each
holds.
An Array keeps its CLASS (a same-class dup, or the caller's own object when it is already frozen —
see detached_option_array) because its own class is part of what a declaration means — a frozen
inclusion: set answers membership with its own include?, and reflection withholds an enum for
anything but an exact Array. A bag becomes a plain Hash — which is what it already became, and axn
reads bags with []/dig only.
136 137 138 139 140 141 142 143 144 145 |
# File 'lib/axn/internal/shape_graph.rb', line 136 def self.detach_option_containers!(validations) validations.each do |key, value| next if key == :shape case value when ::Hash then validations[key] = detached_option_bag(key, value) when ::Array then validations[key] = detached_option_array(value, "`#{key}:`") end end end |
.detached_dup(value) ⇒ Object
A same-CLASS shallow copy. The class is preserved deliberately: a container's own behavior is part of
what a declaration MEANS — an inclusion: set answers membership with its own include? — so replacing
a subclass with a plain Array would change the contract rather than protect it, and would also publish
an enum reflection deliberately withholds for anything but an exact Array.
Only ever called for a container that answers nothing with its own code
(NativeMethods.own_array_methods is empty — see detached_option_array below, which is also where
the frozen escape hatch and the refusal live), so the bound dup runs Ruby's own copy, and every answer
the copy gives is the one the original gave.
109 |
# File 'lib/axn/internal/shape_graph.rb', line 109 def self.detached_dup(value) = KERNEL_DUP.bind_call(value) |
.each_entry(hash) ⇒ Object
Every entry of a caller Hash — THE seam for reading one, so no layer ever asks a Hash subclass to traverse itself.
75 76 77 78 |
# File 'lib/axn/internal/shape_graph.rb', line 75 def self.each_entry(hash, &) HASH_EACH.bind_call(hash, &) nil end |
.fetch(object, name) ⇒ Object
463 464 465 466 467 468 469 470 471 472 473 474 |
# File 'lib/axn/internal/shape_graph.rb', line 463 def self.fetch(object, name) defined_method = bound_method(object, name) return defined_method.call if defined_method begin OBJECT_PUBLIC_SEND.bind_call(object, name) rescue ::NoMethodError => e raise unless name.equal?(NAME_ERROR_NAME.bind_call(e)) NOT_DEFINED end end |
.hash_or_nil(value) ⇒ Object
value when it is genuinely a Hash, else nil. case/when consults the real class through
Module#=== (a C-level check), while is_a? is overridable — and a Hash subclass answering
is_a?(Hash) with false would skip a guard over a shape reflection still consumes.
56 57 58 59 60 |
# File 'lib/axn/internal/shape_graph.rb', line 56 def self.hash_or_nil(value) case value when ::Hash then value end end |
.members(shape) ⇒ Object
A shape's members, captured into an Array this module owns. Captured via each — the one
method walking a container inherently requires, and the one reflection's own member walk uses
— so select/map/any?/to_a are never taken from a caller's Array subclass. Each of
those is separately overridable, so reaching for one hands the caller a second say in what a
guard sees that the walk never needed, and a shorter list than reflection gets.
A non-Hash shape has no members at all, which is what makes this the type test every shape
walk shares: a lie about being a Hash cannot skip a walk, because the walk asks for members
rather than asking the shape what it is.
nil.equal? rather than list.nil?: this is a type test on a caller value, and nil? is
overridable — a members list answering true would hide itself from every guard.
340 341 342 343 |
# File 'lib/axn/internal/shape_graph.rb', line 340 def self.members(shape) hash = hash_or_nil(shape) capture(hash && hash[:members]) end |
.missing?(value) ⇒ Boolean
430 |
# File 'lib/axn/internal/shape_graph.rb', line 430 def self.missing?(value) = NOT_DEFINED.equal?(value) |
.nested_shape(owner) ⇒ Object
The nested shape a config or member carries (validations[:shape]), or nil when it carries
none — so a member too minimal to declare validations is skipped rather than raising. For a
caller-supplied member, whose validations reader is itself something to read without trusting.
421 |
# File 'lib/axn/internal/shape_graph.rb', line 421 def self.nested_shape(owner) = shape_in(read(owner, :validations)) |
.read(object, name) ⇒ Object
The value of name on object, or nil when nothing answers to it — for a caller that treats an
absent reader and a nil one alike (a truthiness test, or a value that gets type-tested anyway).
A caller that must tell them apart uses fetch with missing?.
479 480 481 482 |
# File 'lib/axn/internal/shape_graph.rb', line 479 def self.read(object, name) value = fetch(object, name) missing?(value) ? nil : value end |
.reject_defaulting_option_container!(hash) ⇒ Object
A caller Hash that answers missing keys from a DEFAULT is refused wherever a declaration would store one.
It is the same split the copy exists to close, arriving from the other side: axn copies every container
it stores entry-wise, and a default is not an entry, so the options such a Hash answers are simply not
in the stored contract — while a guard reading the original with [] sees them. The author is told
rather than left to find out, per the option-key rule Contract#_raise_ambiguous_option_key! states: an
option is never silently ignored.
Refusing rather than carrying the default over, because carrying it cannot make the declaration work.
ActiveModel builds each validator's options into a Hash of its OWN (_parse_validates_options), so a
default never reached a validator even when axn stored the caller's bag: expects :a, type: Hash.new(String) raised "must supply :klass" on every CALL — a key the author believes they supplied —
before this copy existed and after it. Declaration is where that is knowable.
The label is YIELDED, so naming the container costs nothing until there is an error to name (see
Contract#_symbol_keyed_bag), and the two readers consulted are Hash's own (see supplies_default? above).
173 174 175 176 177 178 179 180 181 182 183 184 |
# File 'lib/axn/internal/shape_graph.rb', line 173 def self.reject_defaulting_option_container!(hash) return unless supplies_default?(hash) raise ArgumentError, "#{yield} answers a missing key from a Hash default (`Hash.new(…)` or a `default_proc`) rather " \ "than from an entry of its own, and axn cannot carry that into the contract: a declared " \ "container is copied entry-wise so that mutating what you still hold cannot change an " \ "already-declared class, and ActiveModel rebuilds a validator's options into a Hash of its own " \ "besides — so an option supplied through the default is dropped, and the declaration fails on a " \ "call complaining about a key you did supply. Write the options out as entries " \ "(`type: { klass: String }`)." end |
.self_containing_message(member) ⇒ Object
293 294 295 296 297 |
# File 'lib/axn/internal/shape_graph.rb', line 293 def self.(member) "a `shape:` graph#{describe_via(member)} contains itself, so walking it would recurse until the stack " \ "overflows. #{AFTER_DECLARATION} Give the nested shape its own members rather than the shape (or the " \ "member) that encloses it." end |
.shape_in(validations) ⇒ Object
The shape carried by an already-read validations Hash. For axn's OWN configs, whose validations
is the framework's own Hash and so cannot lie — only the shape it holds came from a caller, and
that is what gets type-tested here. Skips the method-table lookup nested_shape needs, which
matters because the redaction walks read every config's shape.
413 414 415 416 |
# File 'lib/axn/internal/shape_graph.rb', line 413 def self.shape_in(validations) hash = hash_or_nil(validations) hash_or_nil(hash && hash[:shape]) end |
.snapshot_node(hash, members) ⇒ Object
ONE node of a caller-supplied shape graph, copied into a Hash this module owns, with members — the
copies the declaration walk built for this node — put in place of whatever it carried.
Copied at DECLARATION because a contract must not change after the class is declared, and storing the caller's object aliases it: a builder Hash reused across two declarations gave the FIRST class members appended after it was declared, and mutating a nested shape changed an already-declared contract from the outside. Copying is what makes "the contract is what you declared" true, and it makes the validation memo sound for free — what it keys on can no longer change underneath it.
A copy rather than freeze: freezing an object the caller owns raises FrozenError on the ordinary
builder-loop pattern (append a member, declare, append another, declare again), which copying instead
makes behave the way its author obviously meant. Sharing one shape Hash across two axns keeps working
for the same reason — each takes its own copy.
Copying a node is deliberately NOT recursive, and takes no members of its own: the walk that recurses is
the declaration walk (Contract#_validate_and_snapshot_shape!), which is also what checks the graph and
what bounds its size, and those cannot be separate passes — a members list that answers two walks
differently would otherwise leave the class holding members no check ever saw.
:container is re-read through [] — the read every consumer makes — rather than taken from the each
copy: a shape whose [] answers differently from its entries is deciding what the contract IS, and the
container check downstream has to see the same answer reflection would.
395 396 397 398 399 400 |
# File 'lib/axn/internal/shape_graph.rb', line 395 def self.snapshot_node(hash, members) copy = copy_entries(hash) copy[:members] = members copy[:container] = hash[:container] copy end |
.supplies_default?(hash) ⇒ Boolean
Whether a caller Hash answers a key it has no ENTRY for — Hash.new(x) or a default_proc. What every
copy above cannot carry: a copy is entry-wise, so the value such a Hash would have answered with is not
in it, while a consumer reading the original with [] gets that value. The two disagree, which is the
split the copy exists to prevent, and the declaration decides which side is the contract (see
reject_defaulting_option_container! below).
Both readers are Hash's own, bound: a subclass can override either, and one that denied its default
would slip past the guard whose whole subject it is. Hash#default takes an optional key, and calling
it without one never runs a default_proc — so neither read can run caller code.
89 90 91 |
# File 'lib/axn/internal/shape_graph.rb', line 89 def self.supplies_default?(hash) !nil.equal?(HASH_DEFAULT.bind_call(hash)) || !nil.equal?(HASH_DEFAULT_PROC.bind_call(hash)) end |
.too_deep_message(member) ⇒ Object
299 300 301 302 303 304 |
# File 'lib/axn/internal/shape_graph.rb', line 299 def self.(member) "a `shape:` graph#{describe_via(member)} nests more than #{MAX_NESTING} levels deep, so walking it would " \ "recurse until the stack overflows — a shape object that builds a fresh nested shape on every read is " \ "endless, and no hand-written shape block reaches that depth. #{AFTER_DECLARATION} Have the shape " \ "return the same finite nested shape each time it is read, or flatten the nesting." end |