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 → Tparse and are the inverse of a Values.serialize_value branch.:booleanis 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
-
.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). -
.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. -
.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.
-
.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
:booleantarget only. -
.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.
-
.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.
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.
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).
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.
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 |