Module: Axn::Internal::Reflection::PropertyNames
- Defined in:
- lib/axn/internal/reflection/property_names.rb
Overview
The three rules a declared name must satisfy to be a JSON property: it must render through Ruby's own to_s
(so the property it names is one fact rather than one answer per reader), that rendering must have a UTF-8
form, and it must not collapse onto a property another declared name already renders as. They are judged in
that order, each being the next one's premise.
These live with reflection because they are judged on what reflection EMITS — the walk reads the property
names out of a built schema rather than predicting them from the declarations. Six mechanisms contribute
names at a node (a top-level field, a subfield leaf at its resolved parent, a shape member at any depth,
a model:-generated <field>_id, a nested key a dotted on: introduces, and a structured type's own
members), and any check that predicted their combined output had to re-derive, per mechanism, both
"does this emit here" and "under what name" — a second copy of a rule that must agree with the emitter,
and every copy that drifted produced either a missed collapse or a rejected legal declaration.
Internal, not an adapter surface (see AGENTS.md's namespace policy): Core::Contract is the only caller.
One rule deliberately does NOT live here — the renderability of an exposes field name, which reaches
the serialized body through Values.serialize_exposed regardless of what any schema emits, so it is not
projection-gated and stays eager in the contract.
A module of functions rather than a class: every entry point is one-shot over a schema or a config list, and the only state any of them carries (a path, a size budget) lives for the length of one call.
Class Method Summary collapse
-
.inspect_field_name(name) ⇒ Object
How a declared name is written into a message.
-
.reject_unrenderable_field_names!(names, kind: "a field name") ⇒ Object
A declared name becomes a JSON property name — in the reflected schema for an inbound field, in serialized output for an outbound one — so it carries the same UTF-8 promise the serializer enforces on a Hash key.
-
.renderable_class_name(value) ⇒ Object
How a foreign value's CLASS — or a class named in its own right, a declared
type:or a tool axn — is written into a message. -
.renderable_label(name) ⇒ Object
How a name is written into a message that names ONE thing rather than distinguishing two spellings: the UTF-8 property it canonicalizes to, falling back to the escaped form above when its bytes have no UTF-8 rendering at all.
- .renderable_module_name(mod) ⇒ Object
- .same_declared_name?(first, second) ⇒ Boolean
-
.validate_inbound!(klass) ⇒ Object
For APP SETUP, which must validate a class's inbound projection without going through its
input_schema: that name belongs to the class, and an adapter base that already defines it keeps it (see Core::SchemaReflection) — so calling the class method runs the adapter's transport-shaped reader, builds no axn projection, and validates nothing. -
.validate_outbound!(klass) ⇒ Object
For
render, which needs the outbound verdict but has no schema of its own to hand over, so it would pay a wholebuild_outputper call. -
.validated_input(klass) ⇒ Object
THE TRIGGER SET, and the guarantee: the rules run before any of the three public paths that expose a JSON projection can return one —
input_schema,output_schema, andAxn::Extensions::Serialization.render. - .validated_output(klass) ⇒ Object
Class Method Details
.inspect_field_name(name) ⇒ Object
How a declared name is written into a message. A String or Symbol is named by its escaped spelling; anything else is named by its CLASS, derived without dispatching anything the name defines.
A name that is neither is reachable only as a shape member's (the field path symbolizes every
declared name before any guard runs), and it gets here having rendered a property through its
to_s — so it is a real object whose inspect is real caller code. Dispatching that inspect
while building the very error the name caused lets the name replace that error with an exception
of its own, and one outside StandardError then escapes class definition entirely. That is the
same hazard the bound inspect above exists to avoid, and it has nothing to do with encoding:
a class name identifies the offender without running a line the offender wrote, exactly as
Reflection::Values#describe_key_classes names a colliding Hash key.
203 204 205 |
# File 'lib/axn/internal/reflection/property_names.rb', line 203 def inspect_field_name(name) field_name_spelling(name) || "a name of class #{renderable_class_name(name)}" end |
.reject_unrenderable_field_names!(names, kind: "a field name") ⇒ Object
A declared name becomes a JSON property name — in the reflected schema for an inbound field, in serialized output for an outbound one — so it carries the same UTF-8 promise the serializer enforces on a Hash key. Canonicalization belongs to the layer that renders the property, so the check and the rendering it predicts cannot disagree.
Runs before any collision comparison: two unrenderable names both canonicalize to nil, so a collision check reached first would compare nil to nil and report a shared property for two names that share none.
254 255 256 257 258 259 260 |
# File 'lib/axn/internal/reflection/property_names.rb', line 254 def reject_unrenderable_field_names!(names, kind: "a field name") names.each do |name| next if Axn::Internal::Reflection::Values.canonical_wire_key(name) raise ArgumentError, (name, kind) end end |
.renderable_class_name(value) ⇒ Object
How a foreign value's CLASS — or a class named in its own right, a declared type: or a tool axn — is
written into a message. Two hazards, one seam, because they are different failures with the same outcome
and a layer that closed one of them has historically missed the other:
- Running the value's own
class/inspectlets it raise INSTEAD of the report, and outside StandardError that escapes the rescue meant to settle the failure.Internal::ClassNameanswers from bound base implementations, so nothing the value defines runs. - The name it answers with is still foreign BYTES. A constant may hold non-UTF-8 ones —
Object.const_set(:"Caf\xE9", Class.new)is accepted andModule#to_sreturns those bytes — so interpolating the name into a UTF-8 message raises Encoding::CompatibilityError from the reporting itself, destroying the failure exactly as a hostileclasswould.
So a class name is composed through Internal::Rendering, which pairs the same undispatched
Internal::ClassName read with a render of its bytes: an ordinary ASCII name is byte-identical, a
Latin-1 one reads as its text, and bytes with no UTF-8 rendering at all come back escaped. That cannot
recurse back into inspect_field_name's class branch above: Module#to_s always answers with a genuine
String ("#<Class:0x…>" for an anonymous class), so field_name_spelling resolves it from its String
branch.
225 |
# File 'lib/axn/internal/reflection/property_names.rb', line 225 def renderable_class_name(value) = Axn::Internal::Rendering.class_name(value) |
.renderable_label(name) ⇒ Object
How a name is written into a message that names ONE thing rather than distinguishing two spellings: the UTF-8 property it canonicalizes to, falling back to the escaped form above when its bytes have no UTF-8 rendering at all.
Every message axn builds is a UTF-8 String, and joining raw non-UTF-8 bytes to one raises
Encoding::CompatibilityError from the reporting itself — so a caller gets an encoding failure instead of
the failure being reported, or loses a log line entirely. Two ASCII-compatible encodings concatenate
fine, which is why this only bites once a message carries non-ASCII text from BOTH sides: a Latin-1
:"caf\xE9" beside a UTF-8 :naïve.
The canonical property is byte-identical to the raw spelling for every ASCII name, so ordinary messages
are unchanged. Shared by every layer that names something in prose — a shape member in a validation
error, a stranded subfield path, a Hash key in a log line, a declared name in a declaration error —
because each deriving its own is how three copies of it appeared, and because the fallback has to be the
SAFE escape: an exotic name's own inspect is caller code that can raise while the message is built.
244 |
# File 'lib/axn/internal/reflection/property_names.rb', line 244 def renderable_label(name) = Values.canonical_wire_key(name) || inspect_field_name(name) |
.renderable_module_name(mod) ⇒ Object
227 |
# File 'lib/axn/internal/reflection/property_names.rb', line 227 def renderable_module_name(mod) = Axn::Internal::Rendering.module_name(mod) |
.same_declared_name?(first, second) ⇒ Boolean
179 180 181 182 183 184 185 186 187 188 189 190 |
# File 'lib/axn/internal/reflection/property_names.rb', line 179 def same_declared_name?(first, second) return true if first.equal?(second) case first when ::String case second when ::String then STRING_NAME_EQ.bind_call(first, second) else false end else false end end |
.validate_inbound!(klass) ⇒ Object
For APP SETUP, which must validate a class's inbound projection without going through its
input_schema: that name belongs to the class, and an adapter base that already defines it keeps it
(see Core::SchemaReflection) — so calling the class method runs the adapter's transport-shaped reader,
builds no axn projection, and validates nothing. Exactly the case that matters most, since a tool
subclassing its adapter's base class is the ordinary shape of one.
Builds axn's projection here instead — the same build the reader performs, through the same one owner
(Schema.build_input_for) — and validates it. The schema is discarded: setup wants the verdict.
Deliberately NOT memoized, unlike the outbound verdict: nothing reads an inbound verdict later, and a
memo would only make a second setup pass skip a check that costs one build.
99 100 101 102 |
# File 'lib/axn/internal/reflection/property_names.rb', line 99 def validate_inbound!(klass) validated_input(klass) { Schema.build_input_for(klass) } nil end |
.validate_outbound!(klass) ⇒ Object
For render, which needs the outbound verdict but has no schema of its own to hand over, so it would
pay a whole build_output per call. That is the one place a memo earns its keep: rendering is a hot
path (measured ~2x per render without it), and the verdict is established once per class.
The narrow consequence, stated rather than hidden: a caller that mutates a retained shape: graph after
the first render is not re-validated HERE. It still is by output_schema, which validates every build,
and the rendered body itself is still protected by the serializer's own runtime defenses (colliding
exposed field names, colliding Hash keys). What is lost is only the earliest warning.
Keyed on the IDENTITY of the config arrays, exactly as _resolved_subfields keys its cache: those are
copy-on-write, so every declaration mints new ones and a grown contract misses with no invalidation hook
to keep in sync. A subclass holds its own ivars, so it never inherits a verdict. The verdict is recorded
only after validation passes, so a failure raises again on every render rather than being swallowed.
117 118 119 120 121 122 123 124 125 |
# File 'lib/axn/internal/reflection/property_names.rb', line 117 def validate_outbound!(klass) configs = klass.external_field_configs # `equal?` on the CACHED value, so a nil cache is simply not equal rather than needing a guard. return nil if configs.equal?(klass.instance_variable_get(:@_axn_validated_outbound)) validate_and_build(configs, direction: :output) { Schema.build_output(configs) } klass.instance_variable_set(:@_axn_validated_outbound, configs) nil end |
.validated_input(klass) ⇒ Object
THE TRIGGER SET, and the guarantee: the rules run before any of the three public paths that expose a
JSON projection can return one — input_schema, output_schema, and
Axn::Extensions::Serialization.render. That set is exhaustive as audited: those are the only public
methods that build or emit property names (Axn::Result defines no to_h/as_json/to_json, and
Schema.build_input/build_output are reached only through them). Adding a fourth projection path
means adding it here, or the guarantee narrows silently.
For a TOOL axn, "first demanded" is made to happen at app setup: Axn::Tools.validate_contracts! projects
every registered tool once, driven under Rails by config.after_initialize and config.to_prepare (see
Axn::RailsIntegration::Engine), and called directly by a non-Rails app. That entry point documents
exactly how wide its coverage is — it depends on an adapter being registered and on the tool being
loaded — rather than implying it is total. Everything it does not reach falls back to first projection,
which is where every non-tool axn is validated anyway. It projects through validate_inbound!/
validate_outbound! rather than through the two readers, because those NAMES may not be axn's: see
validate_inbound!.
Nothing but a projection can be harmed by a colliding or unrenderable name: for an axn that never projects, two names that canonicalize alike stay two distinct fields with their own readers and validations, and the contract works. So validating on demand is not a weaker promise for those actions — it is the promise stated where it is true.
render is a trigger even though it builds no schema — the alternative is that a render-only adapter
learns about a collision from the runtime serialize_exposed defense on a live call instead of at
setup, and that defense is a last line rather than a substitute for telling the author. It is the ONE
path whose verdict is memoized; see validate_outbound!.
A projection that is BUILT is validated, every time — the verdict is not memoized here. The schema is
rebuilt on every call anyway (a caller may mutate the Hash it is handed, so it cannot be shared), and
validating what was just built is what makes the guarantee exact: a caller that retains the mutable
shape: Hash or members Array it declared with, and mutates it afterwards, changes what the schema
emits without changing any config array — so an identity-keyed verdict would be stale and the new
schema would come back unvalidated. Measured at ~16% of the input build and ~59% of the (much smaller)
output build, against a build that had to happen regardless.
resolved: is the class's own cached subfield resolution — the artifact Schema.build_input_for nests
properties from — so the size cap reads the emitter's tree rather than building a second one.
80 81 82 83 |
# File 'lib/axn/internal/reflection/property_names.rb', line 80 def validated_input(klass, &) validate_and_build(klass.internal_field_configs, klass.subfield_configs, direction: :input, resolved: klass._resolved_subfields, &) end |
.validated_output(klass) ⇒ Object
85 86 87 |
# File 'lib/axn/internal/reflection/property_names.rb', line 85 def validated_output(klass, &) validate_and_build(klass.external_field_configs, direction: :output, &) end |