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

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