Module: Axn::Internal::Coercion

Defined in:
lib/axn/internal/coercion.rb

Overview

Inbound wire DECODER — the parse-based inverse of Internal::Reflection::Values.serialize_value, keyed off the same class set so encoder and decoder cannot drift. The single home for the wire→Ruby mapping a coerce: field runs through.

At Internal

rather than in the reflection layer because it is a value-level mechanism with no

presence in the action's surface, and because it runs INSIDE validation — the contract's declaration check, the executor, and ContractForSubfields.resolve_value's read-path transforms are its only callers. The reflection layer derives a JSON view of a contract off the execution path, which is the opposite of what this does.

Its encoder counterpart stays in that layer, because the two have different audiences: the encoder renders a result for a serializing adapter, this decodes input during validation. What must not drift is the CLASS SET both are keyed off, which is an obligation of these two headers rather than of a shared namespace — a namespace never enforced it.

Constant Summary collapse

SUPPORTED =

The coercible target set. Date/DateTime/Time/Symbol/Integer/Float each have a strict, unambiguous String → T parse and are the inverse of a Values.serialize_value branch. :boolean is the one member with no encoder counterpart (a boolean serializes as itself, not a string) — it's a purely inbound tolerance for the string/integer forms a JSON client or Rails form sends. BigDecimal (String→decimal) is still deferred to its own ticket; a coerce: target outside this set raises not-yet-supported at declaration (see Contract#_validate_coercion!).

[Date, DateTime, Time, Symbol, Integer, Float, :boolean].freeze

Class Method Summary collapse

Class Method Details

.already_valid_for_target?(value, targets) ⇒ Boolean

Whether a non-String value is already a valid instance of some declared coercion target other than :boolean — in which case boolean coercion must not fire (coerce-or-leave). Only the class targets are checked; :boolean is a Symbol, and a real true/false is handled idempotently by coerce_boolean itself, so it's fine to leave it out of this "leave it as-is" guard.

Returns:

  • (Boolean)


142
143
144
# File 'lib/axn/internal/coercion.rb', line 142

def already_valid_for_target?(value, targets)
  targets.any? { |t| t.is_a?(Class) && value.is_a?(t) }
end

.coerce_boolean(value) ⇒ Object

Coerce a wire boolean into true/false, or raise ArgumentError (meaning "leave it") for any value that isn't a recognized boolean form. Accepts an already-boolean value (idempotent), the integers 0/1 (a JSON/loosely-typed client sending a flag as a number), and the canonical string forms. Everything else — Integer 2, a Float, a blank/unrecognized string — is declined, so it flows to TypeValidator exactly as an uncoerced value would (never silently becoming true).

Raises:

  • (ArgumentError)


123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/axn/internal/coercion.rb', line 123

def coerce_boolean(value)
  return value if [true, false].include?(value)

  if value.is_a?(Integer)
    return true if value == 1
    return false if value.zero?
  elsif value.is_a?(String)
    normalized = value.strip.downcase
    return true if TRUTHY_STRINGS.include?(normalized)
    return false if FALSY_STRINGS.include?(normalized)
  end

  raise ArgumentError, "#{value.inspect} is not a recognized boolean form"
end

.coerce_config_value(value, config, coerce_input_types:) ⇒ Object

Coerce a config's value when the field has ≥1 coercible member AND opts in (field_coerces?); otherwise return it untouched. THE single place the read path decides-and-coerces, for every field regardless of depth.



164
165
166
167
168
169
170
171
# File 'lib/axn/internal/coercion.rb', line 164

def coerce_config_value(value, config, coerce_input_types:)
  type_opt = config.validations[:type]
  klasses = coercible_klasses(type_opt)
  return value if klasses.empty?
  return value unless field_coerces?(type_opt, coerce_input_types)

  coerce_value(value, klasses)
end

.coerce_value(value, klass_or_klasses) ⇒ Object

Coerce-or-leave: a String is a coercion candidate for every target (a direct Ruby caller passing a real Date, or a JSON-native number, is returned untouched); an Integer or an already-boolean value is additionally a candidate for a :boolean target only. Union targets are tried in declaration order; the first that parses wins; a parse that raises falls through to the next, and if none parse the ORIGINAL value is returned so it hits the normal TypeValidator error. A non-coercible target (e.g. String) is skipped — it never coerces, it's only a validation branch.

A blank string is never coerced: coercion must not change validation strictness, and Symbol's to_sym would otherwise turn a blank required input ("" / " ") into a non-blank Symbol that slips past the presence validator. Leaving it a String means presence/type validation rejects it exactly as it would an uncoerced field. (The parse-based coercers already leave blanks — Date.parse("") raises — so this only changes the Symbol path, and unifies all of them.)



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/axn/internal/coercion.rb', line 84

def coerce_value(value, klass_or_klasses)
  targets = Array(klass_or_klasses)

  # A non-String value only ever coerces to `:boolean` (the integers 0/1, or an already-boolean
  # value idempotently). Handle it here so a bare Integer never reaches the String-only parse
  # coercers below; a String — including "0"/"1" — flows through the ordered loop so union
  # declaration order still decides which target wins. Coerce-or-leave still holds: a value
  # already valid under another declared target (e.g. a real Integer in a `[Integer, :boolean]`
  # union) is left untouched rather than rewritten to a boolean.
  if !value.is_a?(String) && targets.include?(:boolean) && !already_valid_for_target?(value, targets)
    begin
      return coerce_boolean(value)
    rescue ArgumentError, TypeError
      return value
    end
  end

  return value unless value.is_a?(String)
  return value if value.strip.empty?

  targets.each do |klass|
    coercer = COERCERS[klass]
    next unless coercer

    begin
      return coercer.call(value)
    rescue ArgumentError, TypeError
      next
    end
  end

  value
end

.coercible_klasses(type_opt) ⇒ Object

The coercible subset of a type: option's klass(es) — the single source of truth for "what does this field coerce to", consulted by both the declaration-time guard and the runtime step.



148
149
150
151
# File 'lib/axn/internal/coercion.rb', line 148

def coercible_klasses(type_opt)
  klass = type_opt.is_a?(Hash) ? type_opt[:klass] : type_opt
  Array(klass).select { |k| SUPPORTED.include?(k) }
end

.field_coerces?(type_opt, coerce_input_types) ⇒ Boolean

Whether a field coerces this run: its own coerce: tri-state wins (explicit true/false), else the resolved coerce_input_types flag. Single-sourced so every read-path resolution (ContractForSubfields.resolve_value, top-level or subfield) decides identically.

Returns:

  • (Boolean)


156
157
158
159
# File 'lib/axn/internal/coercion.rb', line 156

def field_coerces?(type_opt, coerce_input_types)
  explicit = type_opt.is_a?(Hash) ? type_opt[:coerce] : nil
  explicit.nil? ? coerce_input_types : explicit
end