Module: Axn::Internal::Reflection::Values
- Defined in:
- lib/axn/internal/reflection/values.rb
Overview
The value renderer: a Result's exposures, or any single value, rendered into a JSON-safe form that matches what Reflection::Schema reflects. Core-internal — an adapter renders through Axn::Extensions::Serialization.render — and everything but serialize_value and canonical_wire_key is private.
Class Method Summary collapse
-
.canonical_wire_key(key) ⇒ Object
The canonical UTF-8 property name
keyrenders as, or nil when its bytes have no UTF-8 rendering. -
.serialize_value(value, path: "(exposed value)", seen: nil, reject_opaque: false) ⇒ Object
pathnames the value being serialized, so a failure says WHICH exposure is at fault (items[1].parent, not just "something").
Class Method Details
.canonical_wire_key(key) ⇒ Object
The canonical UTF-8 property name key renders as, or nil when its bytes have no UTF-8 rendering.
Separate from the raise so a field name and a Hash key can share one canonicalization while each
reports the defect in its own terms — the two are the same mechanism but not the same fix.
Public because the same canonicalization is core's answer to what a JSON property name is, so a declaration-time check in Core::Contract can share this one definition rather than re-deriving it; re-privatizing later would force a send into that module.
A String and a Symbol get their rendering WITHOUT dispatching: a String already holds the bytes an
encoder emits for the property (utf8_rendering reads them through bound String methods, and the
answer is copied into a plain String either way), and a Symbol's is bound for the same reason
PropertyNames binds Symbol#inspect. That is not a micro-optimization — this is the canonicalization
every property-name RULE decides on, so a dispatch here is a dispatch inside a verdict. A String
SUBCLASS may define to_s, and one that raised killed a projection the emitter builds fine, replaced a
collision report with its own exception, and — answering differently on a second call — walked an
unrenderable name straight past PropertyNames.reject_unrenderable_field_names!, since a name is
canonicalized to REACH that check and the check canonicalized again to confirm it.
Reading a String's BYTES is the whole of what this promises about a declared name, and it is only half of
what the rules need, so it is not the whole answer either: JSON.generate renders a Hash key through its
to_s, so a name whose rendering disagrees with its bytes would be judged as one property and emitted as
another. That is why PropertyNames refuses a declared name that renders through code of its own
(NativeMethods.native_name_rendering?) before it canonicalizes anything — a String judged on its bytes
is a sound verdict precisely because a name whose bytes and rendering can differ is no longer admitted.
Anything else is rendered by dispatching its own to_s, which for a Hash KEY is unavoidable and is the
work: a caller's data is keyed by whatever it renders as, and the rendered key this returns is the plain
String the body carries, so nothing asks the key again. A declared NAME reaches it only from the
declaration-time duplicate check, which runs before any projection exists to refuse the name — and reads it
exactly once, which is why that check keys a config by this one answer. The bound fallback still covers a
to_s that returns a non-String.
498 499 500 501 502 503 504 505 506 507 508 509 510 511 |
# File 'lib/axn/internal/reflection/values.rb', line 498 def canonical_wire_key(key) rendered = case key when ::Symbol then SYMBOL_RENDERING.bind_call(key) when ::String then key else case (candidate = key.to_s) when ::String then candidate else DEFAULT_TO_S.bind_call(key) end end utf8 = utf8_rendering(rendered) ::String.new(utf8).force_encoding(::Encoding::UTF_8).freeze if utf8 end |
.serialize_value(value, path: "(exposed value)", seen: nil, reject_opaque: false) ⇒ Object
path names the value being serialized, so a failure says WHICH exposure is at fault
(items[1].parent, not just "something"). seen carries the containers open on the current
path — see within_container. reject_opaque additionally rejects a value (or Hash key) that declares
no rendering of its own — one whose to_s is the inherited Object#to_s, or whose only as_json is
the generic one a Rails app adds: honest output, but not presentable output, so it is the caller's
call rather than a universal one.
Two leaves are rejected unconditionally, alongside a cycle and a key collision, because what they
render is not JSON at all: a String whose bytes have no UTF-8 rendering, and a non-finite Float. No
adapter can want a body JSON.generate refuses, and core is the only layer that still knows the
value was at records[3].price — by the time an encoder refuses it, that is gone.
Public for one caller outside this module: Reflection::Schema renders a literal default:
through it, so a schema's wire form and the serializer's agree by construction. Not part of
the adapter surface — a whole result renders through Axn::Extensions::Serialization.render.
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 |
# File 'lib/axn/internal/reflection/values.rb', line 155 def serialize_value(value, path: "(exposed value)", seen: nil, reject_opaque: false) case value when nil, Integer, TrueClass, FalseClass value when String encodable_string!(value, source: value, path:) when Float finite_number!(value, source: value, path:) when Symbol # JSON has no symbol type — render deterministically as its String form, matching # the schema's `type: Symbol` => "string" mapping (Axn::Internal::Reflection::Schema::TYPE_MAP), # rather than relying on the generic `to_s` fallback below (which happens to agree). encodable_string!(value.to_s, source: value, path:) when Numeric # BigDecimal / Rational etc. — emit a JSON number so output matches the schema's "number" type. # JSON has no decimal type (any JSON number is a double), so a Float representation is the correct # wire form; a caller needing exact decimals should expose type: String. Integer/Float are already # handled above. A non-real Numeric (Complex) can't become a Float — fall back to its string form. # # The finiteness check sits OUTSIDE the coercion's rescue: BigDecimal("Infinity") and a Rational # too large for a double both coerce to a non-finite Float, and # Axn::Extensions::Serialization::UnserializableValue is an ArgumentError, so raising inside # that rescue would be swallowed into the string fallback. coerced = coerce_to_float(value) coerced.nil? ? encodable_string!(value.to_s, source: value, path:) : finite_number!(coerced, source: value, path:) when Hash within_container(value, path, seen) do |nested| # Every key check and every key's #to_s already happened in the capture, so this loop only # renders elements under wire keys it is handed — it never touches `value` or a key again. The # list it walks is the capture's own, so `each_with_object` here is Array's own. entries = capture_hash_entries(value, path, reject_opaque:) rendered = entries.each_with_object({}) do |(wire_key, _key, element), acc| acc[wire_key] = serialize_value(element, path: "#{path}.#{wire_key}", seen: nested, reject_opaque:) end no_entries_lost!(rendered.size, entries.size, path) rendered end when Array within_container(value, path, seen) do |nested| # As in the Hash branch, the list being indexed is the capture's own Array, not `value`. # # No dropped-entry backstop here, unlike the Hash branch: `map` yields exactly one rendered # element per captured one, so the counts cannot diverge. An Array has no equivalent of two keys # collapsing into one property — an index is a position, not a projection of a caller's object. capture_elements(value).each_with_index.map do |element, index| serialize_value(element, path: "#{path}[#{index}]", seen: nested, reject_opaque:) end end when Time, DateTime, Date # Rendered as RFC3339/ISO-8601 regardless of Rails, matching the schema's # `date`/`date-time` `format:` (see Reflection::Schema::FORMAT_MAP) — both inside and # outside Rails, so `serialize_exposed` output validates against the reflected schema. encodable_string!(value.iso8601, source: value, path:) else projection = projection_for(value) # Guarded on the SOURCE object, not the Hash it yields: #as_json/#to_h build a fresh Hash on # every call, so an object whose projection points back at it (`to_h => { child: self }`) # would recurse forever with a different Hash identity each time. case projection when *AS_JSON_PROJECTIONS # A :generic_as_json route means the value declares no projection at all — no `as_json`, no # `to_h`, and no `to_hash` for ActiveSupport's generic Object#as_json to delegate to — so what # would render is its instance-variable dump. A :delegated_as_json value does declare one (its # `to_hash`), which that same generic `as_json` renders faithfully, so it is not opaque. if reject_opaque && projection == :generic_as_json raise Axn::Extensions::Serialization::UnserializableValue.new(path:, value:, reason: OPAQUE_AS_JSON_REASON) end within_container(value, path, seen) { |nested| serialize_value(value.as_json, path:, seen: nested, reject_opaque:) } when :to_h within_container(value, path, seen) { |nested| serialize_value(value.to_h, path:, seen: nested, reject_opaque:) } else raise Axn::Extensions::Serialization::UnserializableValue.new(path:, value:, reason: OPAQUE_VALUE_REASON) if reject_opaque && default_to_s?(value) encodable_string!(value.to_s, source: value, path:) end end end |