Class: Axn::Validation::Fields

Inherits:
Base
  • Object
show all
Defined in:
lib/axn/core/validation/fields.rb

Overview

THE one-off validator collector, for every declared config at every level: a top-level field validates against the context facade (which resolves model records and reads by wire key), a subfield against its canonically-resolved parent value. One (field, validations) pair per one-off class; raising/settling is the caller's concern (see Executor#_validate_inbound!).

Defined Under Namespace

Classes: GateFlipValidator

Constant Summary

Constants inherited from Base

Base::ModelValidator, Base::NonEmptinessValidator, Base::OfValidator, Base::ShapeValidator, Base::TypeValidator, Base::ValidateValidator

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from Base

acceptance_admits_nil?, declared_length_checks, declared_length_floor, effective_entry_options, emittable_length_floor?, entry_context_scoped?, entry_effective_gate_keys, entry_effective_option, entry_self_gated?, format_admits_nil?, length_admits_nil?, #method_missing, nil_accepted?, nil_tolerant_validation?, normalize_validator_options, #respond_to_missing?, set_includes_nil?, shared_validation_option_keys, type_admits_nil?, type_klass_admits_nil?, validator_entries, validator_entry_options

Constructor Details

#initialize(source) ⇒ Fields

Returns a new instance of Fields.



13
14
15
16
# File 'lib/axn/core/validation/fields.rb', line 13

def initialize(source)
  super()
  @source = source
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method in the class Axn::Validation::Base

Class Method Details

.collect_errors(field:, validations:, source:, action: nil, reader: nil, config: nil) ⇒ Object

Returns the ActiveModel::Errors for one (field, validations) pair against a source (empty if valid). This is THE facade call site (top-level inbound + outbound): its source is the framework's context/result facade, whose generated reader is safe, so it permits method dispatch unconditionally. (A subfield reaches read_attribute_for_validation via the reader/ resolve_value branches, never the dispatch-gated else, so the flag is a no-op for it here.)



52
53
54
# File 'lib/axn/core/validation/fields.rb', line 52

def self.collect_errors(field:, validations:, source:, action: nil, reader: nil, config: nil)
  errors_for(validator_class_for(field:, validations:), source:, validations:, action:, reader:, config:, permit_method_call: true)
end

.declaration_gate_open?(validations:, action: nil, source: nil, reader: nil, config: nil, permit_method_call: false) ⇒ Boolean

Whether the DECLARATION-level shared gate alone is OPEN — the special case of validator_gate_open? with no per-validator entry in view (nested gates empty). Asks "would ANY validator on this declaration run this pass", the right question when there is no single entry to isolate (e.g. the drift-proof matrix spec, which pins the shared-tier decision directly).

Returns:

  • (Boolean)


146
147
148
# File 'lib/axn/core/validation/fields.rb', line 146

def self.declaration_gate_open?(validations:, action: nil, source: nil, reader: nil, config: nil, permit_method_call: false)
  validator_gate_open?(validations:, entry_options: nil, action:, source:, reader:, config:, permit_method_call:)
end

.errors_for(validator_class, source:, validations:, action: nil, reader: nil, config: nil, permit_method_call: false, shape_ancestry: nil) ⇒ Object

Runs a validator class against a source and returns its ActiveModel::Errors (empty if valid). permit_method_call: governs the dispatch gate in the else branch of read_attribute_for_validation: the facade call site (collect_errors) passes true; a shape member passes its own method_call: opt-in (PRO-2907). It is deliberately independent of action: so the two can be threaded separately.

shape_ancestry: carries a nested shape walk's position — the value/shape pairs open on the current path and how deep it has descended — across this boundary. A nested shape: recurses through ActiveModel rather than by calling itself, so a fresh validator is built per level and the walk state has nowhere else to live; ShapeValidator reads it back off the record it is handed, the same way it reads the threaded action.



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/axn/core/validation/fields.rb', line 83

def self.errors_for(validator_class, source:, validations:, action: nil, reader: nil, config: nil, permit_method_call: false,
                    shape_ancestry: nil)
  validator = validator_class.new(source)

  # Set the action context for model field resolution + symbol-argument delegation
  validator.instance_variable_set(:@action, action)
  validator.instance_variable_set(:@validations, validations)
  validator.instance_variable_set(:@reader, reader)
  validator.instance_variable_set(:@config, config)
  validator.instance_variable_set(:@permit_method_call, permit_method_call)
  validator.instance_variable_set(:@shape_ancestry, shape_ancestry)

  validator.valid?
  validator.errors
end

.validator_class_for(field:, validations:) ⇒ Object

Builds the one-off validator class for a (field, validations) pair. Callers that validate the same contract repeatedly (e.g. ShapeValidator over array elements) can build this once and reuse it across sources via .errors_for, avoiding per-call class compilation.



59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/axn/core/validation/fields.rb', line 59

def self.validator_class_for(field:, validations:)
  Class.new(self) do
    def self.name = "Axn::Validation::Fields::OneOff"

    # A field may legitimately carry no validators at all (e.g. `optional: true` with no
    # type/model, or an `optional:` field carrying only ActiveModel shared options like
    # `strict:`), which `validates` rejects with "You need to supply at least one validation" —
    # an empty set means nothing to enforce. Shared options (gates, strict:, …) aren't
    # validators, so they don't count toward the set.
    validates field, **validations unless Axn::Validation::Base.validator_entries(validations).empty?
  end
end

.validator_gate_open?(validations:, entry_options:, action: nil, source: nil, reader: nil, config: nil, permit_method_call: false) ⇒ Boolean

Whether the gate for ONE validator ENTRY is OPEN for this call — i.e. whether ActiveModel would run that entry's validator this pass. Two gate tiers apply, and AM merges them inside validates: the declaration-level SHARED if:/unless: (validations's own gate keys) and the ENTRY's OWN nested if:/unless: (the if: inside e.g. a model: { ..., if: } or presence: { if: } hash). Decided by ActiveModel ITSELF, never by a hand-rolled mirror of its merge/evaluation: we build a probe validator (subclassing this class, so it inherits the exact method_missing delegation to the action the real validators use) carrying a single custom validator whose NESTED options are the entry's own gates and whose SHARED options are the declaration-level gates — the identical validates :attr, <gated validator>, **shared shape the real declaration takes. AM applies its real defaults.merge(per_validator_options) precedence (measured against activemodel 7.2.2.2: the entry's OWN if:/unless: OVERRIDES the shared one PER KEY; distinct keys AND together) and the identical ActiveSupport callback machinery for the probe and the real validators, so the arity/String/callable resolution AND the tier precedence are AM's, not ours — the gate decision cannot drift. This is THE single gate oracle for the two axn-side checks that live OUTSIDE ActiveModel and must waive themselves exactly when AM would waive the real validator: the executor's model-consistency pass and ShapeValidator's unreadable-member pre-check.

Zero-cost short-circuit: no gate key on EITHER tier → true, building nothing. Declaration-level blank gates are canonicalized away at declaration, so a present shared key is always a real gate. A blank NESTED gate needs no special handling — it is passed to the probe verbatim and AM's own check_conditionals treats it as no gate (and, per the measured merge, a blank nested same-key value still overrides/drops the shared gate before being ignored) — the probe reflects that automatically. A non-Hash entry value (presence: true) carries no nested gates. A condition that raises propagates exactly as during the real valid?. The receiver context (@action/@reader/@config/@permit_method_call) is threaded onto the probe so a Symbol/Proc condition resolves against the same self and action delegation the real validators see.

Returns:

  • (Boolean)


126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/axn/core/validation/fields.rb', line 126

def self.validator_gate_open?(validations:, entry_options:, action: nil, source: nil, reader: nil, config: nil, permit_method_call: false)
  gate_keys = Axn::Internal::FieldConfig::CONDITIONAL_GATE_KEYS
  shared_gates = validations.slice(*gate_keys)
  nested_gates = entry_options.is_a?(Hash) ? entry_options.slice(*gate_keys) : {}
  return true if shared_gates.empty? && nested_gates.empty?

  probe = gate_probe_class_for(shared: shared_gates, nested: nested_gates).new(source)
  probe.instance_variable_set(:@action, action)
  probe.instance_variable_set(:@validations, validations)
  probe.instance_variable_set(:@reader, reader)
  probe.instance_variable_set(:@config, config)
  probe.instance_variable_set(:@permit_method_call, permit_method_call)
  probe.valid?
  probe.gate_open?
end

Instance Method Details

#read_attribute_for_validation(attr) ⇒ Object



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/axn/core/validation/fields.rb', line 18

def read_attribute_for_validation(attr)
  # A subfield reads through the action's generated reader when one exists — the reader IS the
  # field's value (memoized, model-resolving, value-level-default-applying, PRO-2889), so
  # validation sees exactly what user code sees. The reader's memo can't be stale here: the
  # executor clears every subfield reader memo at the inbound-pipeline boundary
  # (_clear_pre_pipeline_memos!), so a value cached by an early pre-settlement read is discarded
  # and this read resolves against the settled wire values. A dotted-name subfield has no reader
  # and resolves through the same shared helper. Top-level fields keep reading their source facade.
  if @action && @reader && @action.respond_to?(@reader)
    @action.public_send(@reader)
  elsif @action && @config&.subfield?
    Axn::Core::ContractForSubfields.resolve_value(@action, @config)
  else
    # Two occupants reach here. A top-level/outbound FACADE read: its source is the framework's
    # own context/result facade, whose per-field reader is a safe generated accessor — method
    # dispatch is always permitted (it's not the caller-object dispatch the method_call gate
    # targets), so the facade call site (collect_errors) passes `permit_method_call: true`. A
    # SHAPE MEMBER read (ShapeValidator): its source is a caller-supplied element, so it honors
    # the member's own `method_call:` opt-in — the same gate as a subfield (PRO-2907). The
    # permission is carried explicitly by each call site (NOT inferred from @action presence):
    # threading the action into shape-member validation — e.g. to resolve a Symbol validation
    # arg or if:/unless: condition against the action — must not silently re-permit dispatch.
    # Malformed sources still read as absent (one doctrine — see FieldResolvers.extract_or_nil):
    # this field's own validators report against nil while the source's own type validation
    # classifies the bad value.
    Axn::Core::FieldResolvers.extract_or_nil(field: attr, provided_data: @source, permit_method_call: @permit_method_call)
  end
end