Class: Axn::Validation::Base

Inherits:
Object
  • Object
show all
Includes:
ActiveModel::Validations
Defined in:
lib/axn/core/validation/base.rb

Overview

Shared kernel for the one-off ActiveModel validator classes Fields and Subfields build: the custom validator constants and symbol-argument delegation to the action. Subclasses supply the value source (read_attribute_for_validation) and how to reach the action (_action_for_validation).

Direct Known Subclasses

Fields

Constant Summary collapse

ModelValidator =

NOTE: exposing the validators as constants here (rather than registering them globally) scopes them to axn's own one-off validator classes, so they can't affect the consuming apps' validators.

Validators::ModelValidator
TypeValidator =
Validators::TypeValidator
ValidateValidator =
Validators::ValidateValidator
OfValidator =
Validators::OfValidator
ShapeValidator =
Validators::ShapeValidator
NonEmptinessValidator =
Validators::NonEmptinessValidator

Class Method Summary collapse

Instance Method Summary collapse

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_name) ⇒ Object

Delegate unknown methods to the action instance so symbol-referenced validation arguments (e.g. inclusion: { in: :valid_channels_for_number }) resolve against the action — for top-level fields and subfields alike.



354
355
356
357
358
359
# File 'lib/axn/core/validation/base.rb', line 354

def method_missing(method_name, ...)
  action = _action_for_validation
  return super unless action && action.respond_to?(method_name, true) # rubocop:disable Style/SafeNavigation

  action.send(method_name, ...)
end

Class Method Details

.acceptance_admits_nil?(entry_opts) ⇒ Boolean

Whether an acceptance: ENTRY would let a nil through. ActiveModel's AcceptanceValidator skips a nil outright unless the entry disables that (allow_nil: false), and even then accepts a value that is a MEMBER of the accept set — so an explicit nil in the set is accepted with the skip disabled. With no set of its own AM compares against its default ["1", true], which excludes nil, so the absence of a set is not tolerance. Membership is the shared literal-set judgment, which answers "unknown" for a set reflection may not read — and unknown resolves to nil-REJECTING, the safe direction.

Returns:

  • (Boolean)


161
162
163
164
165
# File 'lib/axn/core/validation/base.rb', line 161

def self.acceptance_admits_nil?(entry_opts)
  return true unless entry_opts.is_a?(Hash) && entry_opts[:allow_nil] == false

  set_includes_nil?(entry_opts, keys: %i[accept]) == true
end

.declared_length_checks(entry_opts) ⇒ Object

The size checks a length: entry actually runs, resolved the way ActiveModel's LengthValidator resolves its own options (activemodel 7.2.2.2): in:/within: expand into minimum:/maximum: (a beginless/endless range contributing only the bound it has, an exclusive end counting one less), and an entry that is explicitly blank-INtolerant with neither minimum: nor is: of its own picks up AM's implicit minimum: 1. A falsy bound is dropped, mirroring validate_each's own next unless check_value = options[key].

THE single definition of "what does this entry check", read by the floor reader below and by the nil-tolerance judgment, so neither can disagree with the other about one declaration.



268
269
270
271
272
273
274
275
276
277
278
# File 'lib/axn/core/validation/base.rb', line 268

def self.declared_length_checks(entry_opts)
  opts = validator_entry_options(entry_opts)
  checks = opts.slice(:is, :minimum, :maximum).select { |_key, bound| bound }
  if (range = opts[:in] || opts[:within]).is_a?(Range)
    checks[:minimum] = range.min if range.begin
    checks[:maximum] = (range.exclude_end? ? range.end - 1 : range.end) if range.end
  end
  checks[:minimum] = 1 if opts[:allow_blank] == false && checks[:minimum].nil? && checks[:is].nil?

  checks
end

.declared_length_floor(entry_opts) ⇒ Object

The smallest size a length: entry admits, read from the checks it runs. A maximum: 0 names the floor too, by leaving size 0 as the only admissible size. Returns nil when the entry leaves the floor open (a maximum: of 1 or more, or no size key at all), and :unverifiable for a floor AM resolves per call (a Symbol/Proc).

THE single definition of "how small a value may this length: entry be", shared by the emptiness reconciliation at declaration (contract.rb) and by schema reflection's minItems/minProperties/ minLength emission, so the runtime floor and the emitted floor cannot disagree.



288
289
290
291
292
293
294
295
296
297
# File 'lib/axn/core/validation/base.rb', line 288

def self.declared_length_floor(entry_opts)
  checks = declared_length_checks(entry_opts)

  floor = checks[:is] || checks[:minimum]
  max = checks[:maximum]
  return 0 if floor.nil? && max.is_a?(Numeric) && max.zero?
  return :unverifiable unless floor.nil? || floor.is_a?(Numeric)

  floor
end

.effective_entry_options(entry, declaration_options) ⇒ Object

ONE entry's options as validates will hand them to the validator: the declaration-wide shared options with the entry's own merged over them, which is literally what AM builds (defaults.merge(_parse_validates_options(options)), activemodel 7.2.2.2). Every per-entry judgment reads THIS rather than the entry alone, because a shared allow_nil:/allow_blank:/on:/strict: governs how the entry runs just as an entry's own does — and an entry's own value overrides the shared one per key, which the merge order gives for free.

A falsy entry is a disabled validator AM skips outright, so it has no effective options to speak of. The two GATE questions deliberately do not come through here: entry_effective_gate_keys resolves the same two tiers with AM's blankness rule applied per key, and entry_self_gated? asks about an entry's own gate specifically.



59
60
61
62
63
# File 'lib/axn/core/validation/base.rb', line 59

def self.effective_entry_options(entry, declaration_options)
  return {} unless entry

  declaration_options.merge(normalize_validator_options(entry))
end

.emittable_length_floor?(floor) ⇒ Boolean

Whether a floor read by declared_length_floor is one a JSON Schema minItems/minProperties/ minLength can carry: a positive Integer, the only shape a size constraint takes. THE single definition of "does this floor count", read by schema reflection's emission AND by the emptiness reconciliation, which leans on an author's floor only when the schema can advertise the same number — otherwise the runtime would enforce a floor the schema drops.

Two floors are positive yet uncarryable, and neither can hold the emptiness axis: Float::INFINITY (ActiveModel's own spelling for "no size passes", which no finite floor expresses) and a fractional one (which ActiveModel refuses as a bound outright — its LengthValidator accepts a non-negative Integer, Float::INFINITY, a Symbol or a Proc, and raises otherwise at validation time).

Returns:

  • (Boolean)


349
# File 'lib/axn/core/validation/base.rb', line 349

def self.emittable_length_floor?(floor) = floor.is_a?(Integer) && floor.positive?

.entry_context_scoped?(entry_opts) ⇒ Boolean

Whether a validator ENTRY is scoped to an ActiveModel validation CONTEXT — an on: among the options it will run under, its own or the declaration's (effective_entry_options) — which makes it permanently inert: Fields.errors_for calls valid? with no context, so the entry runs on no call at all and whatever it would have rejected is vacuous. Only the key's presence is asked, at either tier: validate installs the context gate on options.key?(:on) whatever the value, so on: nil/false/[] name a context no call is in exactly as on: :publish does.

Distinct from an if:/unless: GATE, which a given call MAY run: reflection counts a gated entry as if its gate were open (static-maximal — stricter than a closed-gate runtime, and safe), while a context-scoped entry has no call on which it applies. THE single definition, shared by the declaration-time nil-skip push-down, the emptiness axis's deferral test, and schema reflection's floor emission. (Not to be confused with a DECLARATION-level on:, which is axn's subfield parent and never reaches a validator entry.)

Returns:

  • (Boolean)


153
# File 'lib/axn/core/validation/base.rb', line 153

def self.entry_context_scoped?(entry_opts) = entry_opts.is_a?(Hash) && entry_opts.key?(:on)

.entry_effective_gate_keys(entry_opts, decl_gates) ⇒ Object

Which gate keys EFFECTIVELY gate a single validator entry, given the declaration-level gates (decl_gates = the sliced :if/:unless off the whole declaration, already blank-canonicalized). Models AM's per-key precedence STRUCTURALLY — which keys are present/blank — never by evaluating a condition. Blankness is the measured predicate AM applies (value.blank? — a Proc/Symbol is never blank; nil/false/""/[] are), so a blank nested value drops the shared gate for that key and is then ignored, leaving the entry UN-gated on it. Declaration-level gates arrive blank-canonicalized, so a present key there is always a real gate. An entry is EFFECTIVELY GATED iff any returned key survives.

THE single definition of "can this validator be skipped at runtime", shared by schema reflection and by the declaration-time nil-skip push-down (contract.rb _type_rejects_nil?), so both judge the same declaration the same way.



242
243
244
245
246
# File 'lib/axn/core/validation/base.rb', line 242

def self.entry_effective_gate_keys(entry_opts, decl_gates)
  Axn::Internal::FieldConfig::CONDITIONAL_GATE_KEYS.reject do |key|
    entry_effective_option(entry_opts, decl_gates, key).blank?
  end
end

.entry_effective_option(entry_opts, declaration_options, key) ⇒ Object

The value of one ActiveModel shared default option (:if/:unless/:on/:strict/…) as it applies to a single validator ENTRY. validates builds each validator's options as declaration_defaults.merge(entry_options) (activemodel 7.2.2.2), so an entry that carries the key OVERRIDES the declaration-level value — including overriding it with a blank/falsy one — and an entry that does not carries the declaration's. THE single definition of that precedence, so every question about a shared option ("is this entry gated?", "would it raise?") resolves the two tiers identically. The QUALIFYING test is the caller's, because it differs per key: gates turn on blankness, strict: on truthiness.



225
226
227
228
# File 'lib/axn/core/validation/base.rb', line 225

def self.entry_effective_option(entry_opts, declaration_options, key)
  nested = entry_opts.is_a?(Hash) ? entry_opts : {}
  nested.key?(key) ? nested[key] : declaration_options[key]
end

.entry_self_gated?(entry_opts) ⇒ Boolean

Whether an entry carries a gate OF ITS OWN — a non-blank nested if:/unless: that can skip just this validator, whatever the rest of the declaration does. Asked through the per-entry gate model above with NO declaration-level gates supplied, which is what "of its own" means: a declaration-level gate skips every validator together, so it is not this entry's. Blankness is AM's own rule (a blank nested gate is a no-op; a Symbol/Proc is never blank), measured because NESTED gates are not canonicalized at declaration the way declaration-level ones are.

THE single definition of "can this one entry be skipped on its own", shared by the emptiness axis's deferral test and by schema reflection's requiredness-relaxation reasoning.

Returns:

  • (Boolean)


257
# File 'lib/axn/core/validation/base.rb', line 257

def self.entry_self_gated?(entry_opts) = entry_effective_gate_keys(entry_opts, {}).any?

.format_admits_nil?(entry_opts) ⇒ Boolean

Whether a format: ENTRY would let a nil through. FormatValidator tests value.to_s against the pattern (activemodel 7.2.2.2), so a nil is tested as the empty string and the pattern decides — with the polarity flipped by key: with: records an error UNLESS the pattern matches, so it tolerates a nil exactly when the pattern matches ""; without: records one WHEN it matches, so it tolerates a nil exactly when the pattern does not. with: is asked first, as AM asks it.

Only a literal Regexp answers: Regexp#match? on one runs no user code, while a Proc/Symbol option is resolved against the record at validation time (AM's resolve_value) and reflection may never run it — unknown, which resolves to nil-REJECTING. Exact-class, since a subclass could override match?. The entry is read in the shape AM acts on, so a bare format: /re/ is judged as the with: it becomes.

Returns:

  • (Boolean)


309
310
311
312
313
314
315
316
317
318
319
# File 'lib/axn/core/validation/base.rb', line 309

def self.format_admits_nil?(entry_opts)
  opts = validator_entry_options(entry_opts)

  if (with = opts[:with]).instance_of?(Regexp)
    with.match?("")
  elsif (without = opts[:without]).instance_of?(Regexp)
    !without.match?("")
  else
    false
  end
end

.length_admits_nil?(entry_opts) ⇒ Boolean

Whether a length: entry lets a NIL through — a different question from what sizes it admits. ActiveModel compares a nil against one check only: validate_each reaches the comparison for a nil when skip_nil_check?(key) holds — key == :maximum && options[:allow_nil].nil? && options[:allow_blank].nil? (activemodel 7.2.2.2) — and a nil measures 0 (nil.to_s.length), which clears any maximum. Every other check records its error on a nil whatever its bound, so a minimum: 0 rejects a nil even while admitting size 0, and a range rejects one through the floor it sets.

An explicit allow_nil: false/allow_blank: false turns that skip off, and a truthy one never reaches here (the generic tolerance branch answers first), so the key's presence is what is asked — mirroring skip_nil_check?, which is private to the validator instance.

Returns:

  • (Boolean)


331
332
333
334
335
336
337
# File 'lib/axn/core/validation/base.rb', line 331

def self.length_admits_nil?(entry_opts)
  opts = validator_entry_options(entry_opts)
  return false unless opts[:allow_nil].nil? && opts[:allow_blank].nil?

  checks = declared_length_checks(opts)
  checks.key?(:maximum) && !checks.key?(:minimum) && !checks.key?(:is)
end

.nil_accepted?(validations) ⇒ Boolean

Whether the field's validators, taken together, permit a nil/omitted value. Drives both input optionality and nullability (adding "null" to the emitted type). A lone validator's allow_nil: doesn't count if another (presence, type, …) still rejects nil.

An entry is nil-tolerant if it's a disabled validator (falsy optfalse or nil, both of which ActiveModel skips), one scoped to a validation context (it never runs, so it rejects nothing), absence (nil is always "absent"), acceptance unless explicitly allow_nil: false (ActiveModel's acceptance is allow_nil by default), a Hash allowing nil/blank, confirmation (ActiveModel compares only when the <attr>_confirmation accessor is non-nil, so the check adds no error of its own on a nil), a maximum-only length: (the one check ActiveModel compares a nil against, and a nil's measured size of 0 clears any maximum — see length_admits_nil?), a format: whose literal pattern admits the empty string a nil is tested as (see format_admits_nil?), a type: at least one of whose declared klasses nil is an instance of (TypeValidator then reports no defect, so the nil is no type violation at all), an exclusion set not containing nil, or an inclusion set that explicitly contains nil. Any other active validator — including a bare true (e.g. numericality: true) — rejects nil.

This is the question requiredness and nullability both turn on, asked identically by schema reflection and by a field config's own optional? so the two can never disagree about the same declaration.

Returns:

  • (Boolean)


101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/axn/core/validation/base.rb', line 101

def self.nil_accepted?(validations)
  # Judge only the REAL validators: ActiveModel's shared options (if:/unless:/on:/strict:/
  # allow_blank:/allow_nil:) ride in the validations hash but aren't validators, so a restored
  # `strict: true` under a tolerance flag must not read as a nil-rejecting validator and wrongly
  # mark the field required. The judgment is static-maximal: gated validators are counted as if
  # their gates were open (a condition can only relax enforcement at runtime, never tighten it) — a
  # context-scoped entry is different in kind, running on no call at all, and counts for nothing.
  v = validator_entries(validations)
  return true if v.empty?

  # The shared options are stripped from the ENTRY set but still govern how each entry runs, so they are
  # handed to the per-entry judgment rather than discarded: `validates` applies a declaration-wide
  # `allow_nil:`/`allow_blank:` to every validator in the call.
  declaration_options = validations.slice(*shared_validation_option_keys)
  v.all? { |key, opt| nil_tolerant_validation?(key, opt, declaration_options) }
end

.nil_tolerant_validation?(key, opt, declaration_options) ⇒ Boolean

declaration_options are the shared options the entry rides alongside — required rather than defaulted, so a caller cannot omit the tier that decides several of these answers and get a quietly wrong one.

Returns:

  • (Boolean)


120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
# File 'lib/axn/core/validation/base.rb', line 120

def self.nil_tolerant_validation?(key, opt, declaration_options)
  return true unless opt # a disabled validator (falsy `opt` — `false`/`nil`); ActiveModel skips it

  # Judged on the options `validates` will actually hand the validator, so a declaration-wide tolerance or
  # context counts exactly as an entry's own does.
  opts = effective_entry_options(opt, declaration_options)
  return true if entry_context_scoped?(opts)
  return true if opts[:allow_nil] || opts[:allow_blank]
  return true if key == :absence
  return true if key == :acceptance && acceptance_admits_nil?(opts)
  return true if key == :confirmation
  return true if key == :format && format_admits_nil?(opts)
  return true if key == :length && length_admits_nil?(opts)
  return true if key == :type && type_admits_nil?(opts)
  return true if key == :exclusion && set_includes_nil?(opts) == false
  return true if key == :inclusion && set_includes_nil?(opts) == true

  false
end

.normalize_validator_options(value) ⇒ Object

Normalize a scalar validator value the way ActiveModel's own validates does, so the tolerance push-down (contract.rb _parse_field_validations) can layer allow_blank:/allow_nil: onto the SAME options hash validates would build — the terse spelling (numericality: true, inclusion: [..]/1..5, format: /re/) then combines transparently with a tolerance flag, matching how it behaves WITHOUT one (PRO-2915). Reuses AM's private _parse_validates_options rather than copying its case statement, so the mapping cannot drift (activemodel 7.2.2.2: TrueClass→{}, Hash→itself, Range/Array→in:, else→with:).



29
# File 'lib/axn/core/validation/base.rb', line 29

def self.normalize_validator_options(value) = _parse_validates_options(value)

.set_includes_nil?(opt, keys: %i[in within])) ⇒ Boolean

Tri-state: nil = can't tell; true/false = nil's membership in the set. Only inspected for in-memory literal collections: reflection must stay side-effect-free, so a dynamic collection (e.g. an ActiveRecord::Relation, whose include? would query the database) is treated as unknown (nil). Detection is identity-based (equal?(nil)), never include?/==: an element with a custom == could itself run user code. A Range's bounds are Comparable, so nil is never a member. rubocop:disable Style/ReturnNilInPredicateMethodDefinition

keys: names where the set lives in the long form, so the one judgment serves every validator that compares a value against a literal set — in:/within: for inclusion/exclusion, accept: for acceptance.

Returns:

  • (Boolean)


199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# File 'lib/axn/core/validation/base.rb', line 199

def self.set_includes_nil?(opt, keys: %i[in within])
  # The set is the collection under one of `keys` (hash long form) or the bare collection itself
  # (shorthand — inclusion: %w[a b], exclusion: [nil, "x"]); the two are equivalent at runtime, so
  # nil-membership is judged the same for both.
  collection = opt.is_a?(Hash) ? keys.filter_map { |key| opt[key] }.first : opt
  return false if collection.is_a?(Range)

  # A Hash is a collection ActiveModel accepts, and its `include?` tests KEYS — so the keys are the
  # members whose nil-membership is asked, judged by the same identity rule as any other set.
  members = collection.instance_of?(Hash) ? collection.keys : collection
  return nil unless members.instance_of?(Array) || (defined?(Set) && members.instance_of?(Set))

  members.any? { |element| element.equal?(nil) }
rescue StandardError
  nil
end

.shared_validation_option_keysObject

ActiveModel's shared "default" validator options — keys that ride alongside validator entries in a validates call but are NOT validators themselves (if:/unless:/on:/strict:/allow_blank:/ allow_nil:). Exposed so the tolerance push-down (contract.rb) can hold them OUT of the per-validator scalar normalization — merging tolerance into strict: true, say, would rewrite it to a Hash and break strict raising. Reuses AM's own canonical list so the set can't drift.



70
# File 'lib/axn/core/validation/base.rb', line 70

def self.shared_validation_option_keys = _validates_default_keys

.type_admits_nil?(entry_opts) ⇒ Boolean

Whether a type: ENTRY would let a nil through — nil is an instance of at least one declared klass (type: [Array, NilClass], type: Object), so TypeValidator finds the value valid and the nil is no type violation at all. Union semantics are TypeValidator's own: a value matching ANY declared klass passes, so one nil-admitting member admits nil.

Returns:

  • (Boolean)


171
172
173
174
# File 'lib/axn/core/validation/base.rb', line 171

def self.type_admits_nil?(entry_opts)
  klasses = Array(entry_opts.is_a?(Hash) ? entry_opts[:klass] : entry_opts)
  klasses.any? { |klass| type_klass_admits_nil?(klass) }
end

.type_klass_admits_nil?(klass) ⇒ Boolean

Whether ONE declared klass would let a nil through, per TypeValidator's own matcher — so the nil-tolerance judgment that drives optional?/requiredness/nullability and the declaration-time nil-skip push-down cannot disagree about the same declaration.

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
187
# File 'lib/axn/core/validation/base.rb', line 179

def self.type_klass_admits_nil?(klass)
  Validators::TypeValidator.value_matches?(nil, klass:)
rescue TypeError
  # A `type:` that is neither a Class/Module nor a known pseudo-type is a broken declaration whose
  # TypeError belongs to validation time, where it already surfaces. Answer "admits nil" so the caller
  # stands down and the declaration behaves exactly as it does with the question unasked, rather than
  # preempting that error here. Scoped to this one call so an unrelated TypeError still propagates.
  true
end

.validator_entries(validations) ⇒ Object

The real VALIDATOR entries in a validations hash — everything that is NOT an ActiveModel shared option (if:/unless:/on:/strict:/allow_blank:/allow_nil:). THE single definition of "is this a validator", shared by the validator-class builder, the gate sweeps, and schema reflection, so "does this field have any validators / do its validators accept nil" is decided one way everywhere. Without it, a shared-only hash like { strict: true } reads as a validator: the builder calls validates and ActiveModel raises "You need to supply at least one validation", and reflection marks the (omittable) field required.



79
# File 'lib/axn/core/validation/base.rb', line 79

def self.validator_entries(validations) = validations.except(*shared_validation_option_keys)

.validator_entry_options(entry) ⇒ Object

ONE validator ENTRY's options as ActiveModel will act on them — the form to read whenever a judgment turns on an entry's CONTENTS. A falsy entry is a disabled validator AM skips, which names nothing at all; every other value is normalized the way validates does, so a bare shorthand (length: 2..5, inclusion: %w[a b]) is read in the form AM expands it to rather than the form the author happened to type.

Normalizing at the READ is the point: entries are also normalized in place by the declaration-time passes (the tolerance push-down, the nil-skip), but those run only under their own conditions — a gated or nil-admitting type entry leaves its siblings exactly as written — so a reader that assumed a Hash would silently skip a constraint ActiveModel enforces. Idempotent on a Hash, so asking here costs nothing where a pass already ran.



42
43
44
45
46
# File 'lib/axn/core/validation/base.rb', line 42

def self.validator_entry_options(entry)
  return {} unless entry

  normalize_validator_options(entry)
end

Instance Method Details

#respond_to_missing?(method_name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


361
362
363
364
365
366
# File 'lib/axn/core/validation/base.rb', line 361

def respond_to_missing?(method_name, include_private = false)
  action = _action_for_validation
  return super unless action

  action.respond_to?(method_name, include_private) || super
end