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. CycleGuard catches 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 a sensitive: 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

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

Returns:

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

Raises:

  • (ArgumentError)


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.self_containing_message(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.

Returns:

  • (Boolean)


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.too_deep_message(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