class Stannum::Errors

An errors object represents a collection of errors.

Most of the time, an end user will not be creating an Errors object directly. Instead, an errors object may be returned by a process that validates or coerces data to an expected form. For one such example, see the Stannum::Constraint and its subclasses.

Internally, an errors object is an Array of errors. Each error is represented by a Hash containing the keys :data, :message, :path and :type.

@example Creating An Errors Object

errors = Stannum::Errors.new

@example Adding Errors

errors.add(:not_numeric)

# Add an error with a custom message.
errors.add(:invalid, message: 'is not valid')

# Add an error with additional data.
errors.add(:out_of_range, min: 0, max: 10)

# Add multiple errors.
errors.add(:first_error).add(:second_error).add(:third_error)

@example Viewing The Errors

errors.empty? #=> false
errors.size   #=> 6

errors.each { |err| } #=> yields each error to the block
errors.to_a           #=> returns an array containing each error

@example Accessing Nested Errors via a Key

errors = Stannum::Errors.new
child  = errors[:spell]
child.size #=> 0
child.to_a #=> []

child.add(:insufficient_mana)
child.size # 1
child.to_a # [{ type: :insufficient_mana, path: [] }]

# Adding an error to a child makes it available on a parent.
errors.size # 1
errors.to_a # [{ type: :insufficient_mana, path: [:spell] }]

@example Accessing Nested Errors via an Index

errors = Stannum::Errors.new
child  = errors[1]

child.size #=> 0
child.to_a #=> []

child.add(:unknown_monster)
child.size # 1
child.to_a # [{ type: :unknown_monster, path: [] }]

# Adding an error to a child makes it available on a parent.
errors.size # 1
errors.to_a # [{ type: :unknown_monster, path: [1] }]

@example Accessing Deeply Nested Errors

errors = Stannum::Errors.new

errors[:towns][1][:name].add(:unpronounceable)
errors.size #=> 1
errors.to_a #=> [{ type: :unpronounceable, path: [:towns, 1, :name] }]

errors[:towns].size #=> 1
errors[:towns].to_a #=> [{ type: :unpronounceable, path: [1, :name] }]

errors[:towns][1].size #=> 1
errors[:towns][1].to_a #=> [{ type: :unpronounceable, path: [:name] }]

errors[:towns][1][:name].size #=> 1
errors[:towns][1][:name].to_a #=> [{ type: :unpronounceable, path: [] }]

# Can also access nested properties via #dig.
errors.dig(:towns, 1, :name).to_a #=> [{ type: :unpronounceable, path: [] }]

@example Replacing Errors

errors = Cuprum::Errors.new
errors[:potions][:ingredients].add(:missing_rabbits_foot)
errors.size #=> 1

other = Cuprum::Errors.new.add(:too_hot, :brew_longer, :foul_smelling)
errors[:potions] = other
errors.size #=> 3
errors.to_a
#=> [
#     { type: :brew_longer, path: [:potions] },
#     { type: :foul_smelling, path: [:potions] },
#     { type: :too_hot, path: [:potions] }
#   ]

@example Replacing Nested Errors

errors = Cuprum::Errors.new
errors[:armory].add(:empty)

other = Cuprum::Errors.new
other.dig(:weapons, 0).add(:needs_sharpening)
other.dig(:weapons, 1).add(:rusty).add(:out_of_ammo)

errors[:armory] = other
errors.size #=> 3
errors.to_a
#=> [
#     { type: needs_sharpening, path: [:armory, :weapons, 0] },
#     { type: out_of_ammo, path: [:armory, :weapons, 1] },
#     { type: rusty, path: [:armory, :weapons, 1] }
#   ]

Public Class Methods

new() click to toggle source
# File lib/stannum/errors.rb, line 143
def initialize
  @children = Hash.new { |hsh, key| hsh[key] = self.class.new }
  @cache    = Set.new
  @errors   = []
end

Public Instance Methods

==(other) click to toggle source

Checks if the other errors object contains the same errors.

@return [true, false] true if the other object is an errors object or an

array with the same class and errors, otherwise false.
# File lib/stannum/errors.rb, line 153
def ==(other)
  return false unless other.is_a?(Array) || other.is_a?(self.class)

  return false unless empty? == other.empty?

  compare_hashed_errors(other)
end
Also aliased as: eql?
[](key) click to toggle source

Accesses a nested errors object.

Each errors object can have one or more children, each of which is itself an errors object. These nested errors represent errors on some subset of the main object - for example, a failed validation of a named property, of the value in a key-value pair, or of an indexed value in an ordered collection.

The children are created as needed and are stored with either an integer or a symbol key. Calling errors multiple times will always return the same errors object. Likewise, calling errors multiple times will return the same object, and calling errors will return that same errors object as well.

@param key [Integer, String, Symbol] The key or index of the referenced

value, item, or property.

@return [Stannum::Errors] an Errors object.

@raise [ArgumentError] if the key is not a String, Symbol or Integer.

@example Accessing Nested Errors via a Key

errors = Stannum::Errors.new
child  = errors[:spell]
child.size #=> 0
child.to_a #=> []

child.add(:insufficient_mana)
child.size # 1
child.to_a # [{ type: :insufficient_mana, path: [] }]

# Adding an error to a child makes it available on a parent.
errors.size # 1
errors.to_a # [{ type: :insufficient_mana, path: [:spell] }]

@example Accessing Nested Errors via an Index

errors = Stannum::Errors.new
child  = errors[1]

child.size #=> 0
child.to_a #=> []

child.add(:unknown_monster)
child.size # 1
child.to_a # [{ type: :unknown_monster, path: [] }]

# Adding an error to a child makes it available on a parent.
errors.size # 1
errors.to_a # [{ type: :unknown_monster, path: [1] }]

@example Accessing Deeply Nested Errors

errors = Stannum::Errors.new

errors[:towns][1][:name].add(:unpronounceable)
errors.size #=> 1
errors.to_a #=> [{ type: :unpronounceable, path: [:towns, 1, :name] }]

errors[:towns].size #=> 1
errors[:towns].to_a #=> [{ type: :unpronounceable, path: [1, :name] }]

errors[:towns][1].size #=> 1
errors[:towns][1].to_a #=> [{ type: :unpronounceable, path: [:name] }]

errors[:towns][1][:name].size #=> 1
errors[:towns][1][:name].to_a #=> [{ type: :unpronounceable, path: [] }]

@see []=

@see dig

# File lib/stannum/errors.rb, line 231
def [](key)
  validate_key(key)

  @children[key]
end
[]=(key, value) click to toggle source

Replaces the child errors with the specified errors object or Array.

If the given value is nil or an empty array, the []= operator will remove the child errors object at the given key, removing all errors within that namespace and all namespaces nested inside it.

If the given value is an errors object or an Array of errors object, the []= operation will replace the child errors object at the given key, removing all existing errors and adding the new errors. Each added error will use its nested path (if any) as a relative path from the given key.

@param key [Integer, String, Symbol] The key or index of the referenced

value, item, or property.

@param value [Stannum::Errors, Array, nil] The errors to insert with

the specified path.

@return [Object] the value passed in.

@raise [ArgumentError] if the key is not a String, Symbol or Integer.

@raise [ArgumentError] if the value is not a valid errors object, Array of

errors hashes, empty Array, or nil.

@example Replacing Errors

errors = Cuprum::Errors.new
errors[:potions][:ingredients].add(:missing_rabbits_foot)
errors.size #=> 1

other = Cuprum::Errors.new.add(:too_hot, :brew_longer, :foul_smelling)
errors[:potions] = other
errors.size #=> 3
errors.to_a
#=> [
#     { type: :brew_longer, path: [:potions] },
#     { type: :foul_smelling, path: [:potions] },
#     { type: :too_hot, path: [:potions] }
#   ]

@example Replacing Nested Errors

errors = Cuprum::Errors.new
errors[:armory].add(:empty)

other = Cuprum::Errors.new
other.dig(:weapons, 0).add(:needs_sharpening)
other.dig(:weapons, 1).add(:rusty).add(:out_of_ammo)

errors[:armory] = other
errors.size #=> 3
errors.to_a
#=> [
#     { type: needs_sharpening, path: [:armory, :weapons, 0] },
#     { type: out_of_ammo, path: [:armory, :weapons, 1] },
#     { type: rusty, path: [:armory, :weapons, 1] }
#   ]

@see []

# File lib/stannum/errors.rb, line 294
def []=(key, value)
  validate_key(key)

  value = normalize_value(value, allow_nil: true)

  @children[key] = value
end
add(type, message: nil, **data) click to toggle source

Adds an error of the specified type.

@param type [String, Symbol] The error type. This should be a string or

symbol with one or more underscored, dot-separated values.

@param message [String] A custom error message to display. Optional;

defaults to nil.

@param data [Hash<Symbol, Object>] Additional data to store about the

error, such as the expected type or the min/max values of the expected
range. Optional; defaults to an empty Hash.

@return [Stannum::Errors] the errors object.

@raise [ArgumentError] if the type or message are invalid.

@example Adding An Error

errors = Stannum::Errors.new.add(:not_found)

@example Adding An Error With A Message

errors = Stannum::Errors.new.add(:not_found, message: 'is missing')

@example Adding Multiple Errors

errors = Stannum::Errors.new
errors
  .add(:not_numeric)
  .add(:not_integer, message: 'is outside the range')
  .add(:not_in_range)
# File lib/stannum/errors.rb, line 328
def add(type, message: nil, **data)
  error  = build_error(data: data, message: message, type: type)
  hashed = error.hash

  return self if @cache.include?(hashed)

  @errors << error
  @cache  << hashed

  self
end
blank?()
Alias for: empty?
count()
Alias for: size
dig(first, *rest) click to toggle source

Accesses a (possibly deeply) nested errors object.

Similiar to the [] method, but can access a deeply nested errors object as well. The dig method can take either a list of one or more keys (Integers, Strings, and Symbols) as arguments, or an Array of keys. Calling errors.dig is equivalent to calling errors[] with each key in sequence.

@return [Stannum::Errors] the nested error object at the specified path.

@raise [ArgumentError] if the keys are not Strings, Symbols or Integers.

@overload dig(keys)

@param keys [Array<Integer, String, Symbol>] The path to the nested
  errors object, as an array of Integers, Strings, and Symbols.

@overload dig(*keys)

@param keys [Array<Integer, String, Symbol>] The path to the nested
  errors object, as individual Integers, Strings, and Symbols.

@example Accessing Nested Errors via a Key

errors = Stannum::Errors.new
child  = errors.dig(:spell)
child.size #=> 0
child.to_a #=> []

child.add(:insufficient_mana)
child.size # 1
child.to_a # [{ type: :insufficient_mana, path: [] }]

# Adding an error to a child makes it available on a parent.
errors.size # 1
errors.to_a # [{ type: :insufficient_mana, path: [:spell] }]

@example Accessing Nested Errors via an Index

errors = Stannum::Errors.new
child  = errors.dig(1)

child.size #=> 0
child.to_a #=> []

child.add(:unknown_monster)
child.size # 1
child.to_a # [{ type: :unknown_monster, path: [] }]

# Adding an error to a child makes it available on a parent.
errors.size # 1
errors.to_a # [{ type: :unknown_monster, path: [1] }]

@example Accessing Deeply Nested Errors

errors = Stannum::Errors.new

errors.dig(:towns, 1, :name).add(:unpronounceable)
errors.size #=> 1
errors.to_a #=> [{ type: :unpronounceable, path: [:towns, 1, :name] }]

errors.dig(:towns).size #=> 1
errors.dig(:towns).to_a #=> [{ type: :unpronounceable, path: [1, :name] }]

errors.dig(:towns, 1).size #=> 1
errors.dig(:towns, 1).to_a #=> [{ type: :unpronounceable, path: [:name] }]

errors.dig(:towns, 1, :name).size #=> 1
errors.dig(:towns, 1, :name).to_a #=> [{ type: :unpronounceable, path: [] }]

@see []

# File lib/stannum/errors.rb, line 406
def dig(first, *rest)
  path = first.is_a?(Array) ? first : [first, *rest]

  path.reduce(self) { |errors, segment| errors[segment] }
end
dup() click to toggle source

Creates a deep copy of the errors object.

@return [Stannum::Errors] the copy of the errors object.

# File lib/stannum/errors.rb, line 415
def dup # rubocop:disable Metrics/MethodLength
  child = self.class.new

  each do |error|
    child # rubocop:disable Style/SingleArgumentDig
      .dig(error.fetch(:path, []))
      .add(
        error.fetch(:type),
        message: error[:message],
        **error.fetch(:data, {})
      )
  end

  child
end
each() { |merge(path: [])| ... } click to toggle source

@overload each

Returns an Enumerator that iterates through the errors.

@return [Enumerator]

@overload each

Iterates through the errors, yielding each error to the provided block.

@yieldparam error [Hash<Symbol=>Object>] The error object. Each error
  is a hash containing the keys :data, :message, :path and :type.
# File lib/stannum/errors.rb, line 441
def each
  return to_enum(:each) { size } unless block_given?

  @errors.each { |item| yield item.merge(path: []) }

  @children.each do |path, child|
    child.each do |item|
      yield item.merge(path: item.fetch(:path, []).dup.unshift(path))
    end
  end
end
empty?() click to toggle source

Checks if the errors object contains any errors.

@return [true, false] true if the errors object has no errors, otherwise

false.
# File lib/stannum/errors.rb, line 457
def empty?
  @errors.empty? && @children.all?(&:empty?)
end
Also aliased as: blank?
eql?(other)
Alias for: ==
group_by_path() { |error| ... } click to toggle source

Groups the errors by the error path.

Generates a Hash whose keys are the unique error :path values. For each path, the corresponding value is the Array of all errors with that path.

This will flatten paths: an error with path [:parts] will be grouped in a separate array from a part with path [:parts, :assemblies].

Errors with an empty path will be grouped with a key of an empty Array.

@return [Hash<Array, Array>] the errors grouped by the error path.

@overload group_by_path

@overload group_by_path(&block)

Groups the values returned by the block by the error path.

@yieldparam error [Hash<Symbol>] the error Hash.
# File lib/stannum/errors.rb, line 480
def group_by_path
  grouped = Hash.new { |hsh, key| hsh[key] = [] }

  each do |error|
    path  = error[:path]
    value = block_given? ? yield(error) : error

    grouped[path] << value
  end

  grouped
end
inspect() click to toggle source

@return [String] a human-readable representation of the object.

Calls superclass method
# File lib/stannum/errors.rb, line 494
def inspect
  oid = super[2...-1].split.first.split(':').last

  "#<#{self.class.name}:#{oid} @summary=%{#{summary}}>"
end
merge(value) click to toggle source

Adds the given errors to a copy of the errors object.

Creates a copy of the errors object, and then adds each error in the passed in errors object or array to the copy. The copy will thus contain all of the errors from the original object and all of the errors from the passed in object. The original object is not changed.

@param value [Stannum::Errors, Array] The errors to add to the

copied errors object.

@return [Stannum::Errors] the copied errors object.

@raise [ArgumentError] if the value is not a valid errors object or Array

of errors hashes.

@see update.

# File lib/stannum/errors.rb, line 516
def merge(value)
  value = normalize_value(value, allow_nil: false)

  dup.update_errors(value)
end
size() click to toggle source

The number of errors in the errors object.

@return [Integer] the number of errors.

# File lib/stannum/errors.rb, line 525
def size
  @errors.size + @children.each_value.reduce(0) do |total, child|
    total + child.size
  end
end
Also aliased as: count
summary() click to toggle source

Generates a text summary of the errors.

@return [String] the text summary.

# File lib/stannum/errors.rb, line 535
def summary
  with_messages
    .map { |error| generate_summary_item(error) }
    .join(', ')
end
to_a() click to toggle source

Generates an array of error objects.

Each error is a hash containing the keys :data, :message, :path and :type.

@return [Array<Hash>] the error objects.

# File lib/stannum/errors.rb, line 546
def to_a
  each.to_a
end
update(value) click to toggle source

Adds the given errors to the errors object.

Adds each error in the passed in errors object or array to the current errors object. It will then contain all of the original errors and all of the errors from the passed in object. This changes the current object.

@param value [Stannum::Errors, Array] The errors to add to the

current errors object.

@return [self] the current errors object.

@raise [ArgumentError] if the value is not a valid errors object or Array

of errors hashes.

@see merge.

# File lib/stannum/errors.rb, line 565
def update(value)
  value = normalize_value(value, allow_nil: false)

  update_errors(value)
end
with_messages(force: false, strategy: nil) click to toggle source

Creates a copy of the errors and generates error messages for each error.

@param force [Boolean] If true, overrides any messages already defined for

the errors.

@param strategy [#call] The strategy to use to generate the error

messages.

@return [Stannum::Errors] the copy of the errors object.

# File lib/stannum/errors.rb, line 579
def with_messages(force: false, strategy: nil)
  strategy ||= Stannum::Messages.strategy

  dup.tap do |errors|
    errors.each_error do |error|
      next unless force || error[:message].nil? || error[:message].empty?

      message = strategy.call(error[:type], **(error[:data] || {}))

      error[:message] = message
    end
  end
end

Protected Instance Methods

each_error(&block) click to toggle source
# File lib/stannum/errors.rb, line 595
def each_error(&block)
  return enum_for(:each_error) unless block_given?

  @errors.each(&block)

  @children.each_value do |child|
    child.each_error(&block)
  end
end
update_errors(other_errors) click to toggle source
# File lib/stannum/errors.rb, line 605
def update_errors(other_errors)
  other_errors.each do |error|
    dig(error.fetch(:path, []))
      .add(
        error.fetch(:type),
        message: error[:message],
        **error.fetch(:data, {})
      )
  end

  self
end

Private Instance Methods

build_error(data:, message:, type:) click to toggle source
# File lib/stannum/errors.rb, line 620
def build_error(data:, message:, type:)
  type = normalize_type(type)
  msg  = normalize_message(message)

  { data: data, message: msg, type: type }
end
compare_hashed_errors(other_errors) click to toggle source
# File lib/stannum/errors.rb, line 627
def compare_hashed_errors(other_errors)
  hashes       = Set.new(map(&:hash))
  other_hashes = Set.new(other_errors.map(&:hash))

  hashes == other_hashes
end
generate_summary_item(error) click to toggle source
# File lib/stannum/errors.rb, line 634
def generate_summary_item(error)
  path = generate_summary_path(error[:path])

  return error[:message] if path.nil? || path.empty?

  "#{path}: #{error[:message]}"
end
generate_summary_path(path) click to toggle source
# File lib/stannum/errors.rb, line 642
def generate_summary_path(path)
  return nil if path.empty?

  return path.first.to_s if path.size == 1

  path[1..-1].reduce(path.first.to_s) do |str, item|
    item.is_a?(Integer) ? "#{str}[#{item}]" : "#{str}.#{item}"
  end
end
invalid_value_error(allow_nil) click to toggle source
# File lib/stannum/errors.rb, line 652
def invalid_value_error(allow_nil)
  values = ['an instance of Stannum::Errors', 'an array of error hashes']
  values << 'nil' if allow_nil

  'value must be ' + # rubocop:disable Style/StringConcatenation
    tools.array_tools.humanize_list(values, last_separator: ' or ')
end
normalize_array_item(item, allow_nil:) click to toggle source
# File lib/stannum/errors.rb, line 660
def normalize_array_item(item, allow_nil:)
  unless item.is_a?(Hash) && item.key?(:type)
    raise ArgumentError, invalid_value_error(allow_nil)
  end

  item
end
normalize_array_value(ary, allow_nil:) click to toggle source
# File lib/stannum/errors.rb, line 668
def normalize_array_value(ary, allow_nil:)
  child = self.class.new

  ary.each do |item|
    err  = normalize_array_item(item, allow_nil: allow_nil)
    data = err.fetch(:data, {})
    path = err.fetch(:path, [])

    child.dig(path).add(err[:type], message: err[:message], **data) # rubocop:disable Style/SingleArgumentDig
  end

  child
end
normalize_message(message) click to toggle source
# File lib/stannum/errors.rb, line 682
def normalize_message(message)
  return if message.nil?

  unless message.is_a?(String)
    raise ArgumentError, 'message must be a String'
  end

  raise ArgumentError, "message can't be blank" if message.empty?

  message
end
normalize_type(type) click to toggle source
# File lib/stannum/errors.rb, line 694
def normalize_type(type)
  raise ArgumentError, "error type can't be nil" if type.nil?

  unless type.is_a?(String) || type.is_a?(Symbol)
    raise ArgumentError, 'error type must be a String or Symbol'
  end

  raise ArgumentError, "error type can't be blank" if type.empty?

  type.to_s
end
normalize_value(value, allow_nil: false) click to toggle source
# File lib/stannum/errors.rb, line 706
def normalize_value(value, allow_nil: false)
  return self.class.new if value.nil? && allow_nil

  return value.dup if value.is_a?(self.class)

  if value.is_a?(Array)
    return normalize_array_value(value, allow_nil: allow_nil)
  end

  raise ArgumentError, invalid_value_error(allow_nil)
end
tools() click to toggle source
# File lib/stannum/errors.rb, line 718
def tools
  SleepingKingStudios::Tools::Toolbelt.instance
end
validate_key(key) click to toggle source
# File lib/stannum/errors.rb, line 722
def validate_key(key)
  return if key.is_a?(Integer) || key.is_a?(Symbol) || key.is_a?(String)

  raise ArgumentError,
    'key must be an Integer, a String or a Symbol',
    caller(1..-1)
end