Class: Stannum::Contracts::Base

Inherits:
Stannum::Constraints::Base show all
Defined in:
lib/stannum/contracts/base.rb

Overview

A Contract aggregates constraints about the given object.

Examples:

Creating A Contract With Constraints

numeric_constraint =
  Stannum::Constraint.new(type: 'not_numeric', negated_type: 'numeric') do |actual|
    actual.is_a?(Numeric)
  end
integer_constraint =
  Stannum::Constraint.new(type: 'not_integer', negated_type: 'integer') do |actual|
    actual.is_a?(Integer)
  end
range_constraint =
  Stannum::Constraint.new(type: 'not_in_range', negated_type: 'in_range') do |actual|
    actual >= 0 && actual <= 10 rescue false
  end
contract =
  Stannum::Contracts::Base.new
  .add_constraint(numeric_constraint)
  .add_constraint(integer_constraint)
  .add_constraint(range_constraint)

With An Object That Matches None Of The Constraints

contract.matches?(nil) #=> false
errors = contract.errors_for(nil) #=> Cuprum::Errors
errors.to_a
#=> [
  { type: 'not_numeric',  data: {}, path: [], message: nil },
  { type: 'not_integer',  data: {}, path: [], message: nil },
  { type: 'not_in_range', data: {}, path: [], message: nil }
]

contract.does_not_match?(nil) #=> true
errors = contract.negated_errors_for(nil) #=> Cuprum::Errors
errors.to_a
#=> []

With An Object That Matches Some Of The Constraints

contract.matches?(11) #=> false
contract.errors_for(11).to_a
#=> [
  { type: 'not_in_range', data: {}, path: [], message: nil }
]

contract.does_not_match?(11) #=> true
contract.negated_errors_for(11).to_a
#=> [
  { type: 'numeric',  data: {}, path: [], message: nil },
  { type: 'integer',  data: {}, path: [], message: nil }
]

With An Object That Matches All Of The Constraints

contract.matches?(5)        #=> true
contract.errors_for(5).to_a #=> []

contract.does_not_match?(5) #=> false
contract.negated_errors_for(5)
#=> [
  { type: 'numeric',  data: {}, path: [], message: nil },
  { type: 'integer',  data: {}, path: [], message: nil },
  { type: 'in_range', data: {}, path: [], message: nil }
]

Creating A Contract With A Sanity Constraint

format_constraint =
  Stannum::Constraint.new(type: 'invalid_format', negated_type: 'valid_format') do |actual|
    actual =~ /\A0x[0-9A-Fa-f]*\z/
  end
length_constraint =
  Stannum::Constraint.new(type: 'invalid_length', negated_type: 'valid_length') do |actual|
    actual.length > 2
  end
string_constraint = Stannum::Constraints::Type.new(String)
contract =
  Stannum::Contracts::Base.new
  .add_constraint(string_constraint, sanity: true)
  .add_constraint(format_constraint)
  .add_constraint(length_constraint)

With An Object That Does Not Match The Sanity Constraint

contract.matches?(nil) #=> false
errors = contract.errors_for(nil) #=> Cuprum::Errors
errors.to_a
#=> [
  {
    data:    { type: String},
    message: nil,
    path:    [],
    type:    'stannum.constraints.is_not_type'
  }
]

contract.does_not_match?(nil) #=> true
errors = contract.negated_errors_for(nil) #=> Cuprum::Errors
errors.to_a
#=> []

Direct Known Subclasses

Stannum::Contract

Constant Summary

Constants inherited from Stannum::Constraints::Base

Stannum::Constraints::Base::NEGATED_TYPE, Stannum::Constraints::Base::TYPE

Instance Attribute Summary

Attributes inherited from Stannum::Constraints::Base

#options

Instance Method Summary collapse

Methods inherited from Stannum::Constraints::Base

#clone, #dup, #message, #negated_message, #negated_type, #type, #with_options

Constructor Details

#initialize(**options, &block) ⇒ Base

Returns a new instance of Base.

Parameters:

  • options (Hash<Symbol, Object>)

    Configuration options for the contract. Defaults to an empty Hash.



107
108
109
110
111
112
113
114
# File 'lib/stannum/contracts/base.rb', line 107

def initialize(**options, &block)
  @constraints  = []
  @concatenated = []

  super(**options)

  define_constraints(&block)
end

Instance Method Details

#==(other) ⇒ true, false

Performs an equality comparison.

Parameters:

  • other (Object)

    The object to compare.

Returns:

  • (true, false)

    true if the other object has the same class, options, and constraints; otherwise false.



122
123
124
# File 'lib/stannum/contracts/base.rb', line 122

def ==(other)
  super && equal_definitions?(other)
end

#add_constraint(constraint, sanity: false, **options) ⇒ self

Adds a constraint to the contract.

When the contract is matched with an object, the constraint will be evaluated with the object and the errors updated accordingly.

Parameters:

  • constraint (Stannum::Constraints::Base)

    The constraint to add.

  • sanity (true, false) (defaults to: false)

    Marks the constraint as a sanity constraint, which is always matched first and will always short-circuit on a failed match.

  • options (Hash<Symbol, Object>)

    Options for the constraint. These can be used by subclasses to define the value and error mappings for the constraint.

Returns:

  • (self)

    the contract.



140
141
142
143
144
145
146
147
148
149
150
# File 'lib/stannum/contracts/base.rb', line 140

def add_constraint(constraint, sanity: false, **options)
  validate_constraint(constraint)

  @constraints << Stannum::Contracts::Definition.new(
    constraint: constraint,
    contract:   self,
    options:    options.merge(sanity: sanity)
  )

  self
end

#concat(other) ⇒ Stannum::Contract

Concatenate the constraints from the given other contract.

Merges the constraints from the concatenated contract into the original. This is a dynamic process - if constraints are added to the concatenated contract at a later point, they will also be added to the original. This is also recursive - concatenating a contract will also merge the constraints from any contracts that were themselves concatenated in the concatenated contract.

There are two approaches for adding one contract to another. The first and simplest is to take advantage of the fact that each contract is, itself, a constraint. Adding the new contract to the original via #add_constraint works in most cases - the new contract will be called during #matches? and when generating errors. However, functionality that inspects the constraints directly (such as the :allow_extra_keys functionality in HashContract) will fail.

Concatenating a contract in another is a much closer relationship. Each time the constraints on the original contract are enumerated, it will also yield the constraints from the concatenated contract (and from any contracts that are concatenated in that contract, recursively).

To sum up, use #add_constraint when you want to constrain a property of the actual object with a contract. Use #concat when you want to add more constraints about the object itself.

Examples:

Concatenating A Contract

concatenated_contract = Stannum::Contract.new
  .add_constraint(Stannum::Constraint.new { |int| int < 10 })

original_contract = Stannum::Contract.new
  .add_constraint(Stannum::Constraint.new { |int| int >= 0 })
  .concat(concatenated_contract)

original_contract.matches?(-1) #=> a failing result
original_contract.matches?(0)  #=> a passing result
original_contract.matches?(5)  #=> a passing result
original_contract.matches?(10) #=> a failing result

Parameters:

Returns:

See Also:



196
197
198
199
200
201
202
# File 'lib/stannum/contracts/base.rb', line 196

def concat(other)
  validate_contract(other)

  @concatenated << other

  self
end

#does_not_match?(actual) ⇒ true, false

Checks that none of the added constraints match the object.

If the contract defines sanity constraints, the sanity constraints will be matched first. If any of the sanity constraints fail (#does_not_match? for the constraint returns true), then this method will immediately return true and all subsequent constraints will be skipped.

Parameters:

  • actual (Object)

    The object to match.

Returns:

  • (true, false)

    True if none of the constraints match the given object; otherwise false. If there are no constraints, returns true.

See Also:



219
220
221
222
223
224
225
226
227
228
229
230
231
# File 'lib/stannum/contracts/base.rb', line 219

def does_not_match?(actual)
  each_pair(actual) do |definition, value|
    if definition.contract.match_negated_constraint(definition, value)
      next unless definition.sanity?

      return true
    end

    return false
  end

  true
end

#each_constraintEnumerator #each_constraint {|definition| ... } ⇒ Object

Iterates through the constraints defined for the contract.

Any constraints defined on concatenated contracts are yielded, followed by any constraints defined on the contract itself.

Each constraint is represented as a Stannum::Contracts::Definition, which encapsulates the constraint, the original contract, and the options specified by #add_constraint.

If the contract defines sanity constraints, the sanity constraints will be returned or yielded first, followed by the remaining constraints.

Overloads:

  • #each_constraintEnumerator

    Returns An enumerator for the constraint definitions.

    Returns:

    • (Enumerator)

      An enumerator for the constraint definitions.

  • #each_constraint {|definition| ... } ⇒ Object

    Yield Parameters:

See Also:



254
255
256
257
258
259
260
261
262
263
264
# File 'lib/stannum/contracts/base.rb', line 254

def each_constraint
  return enum_for(:each_constraint) unless block_given?

  each_unscoped_constraint do |definition|
    yield definition if definition.sanity?
  end

  each_unscoped_constraint do |definition| # rubocop:disable Style/CombinableLoops
    yield definition unless definition.sanity?
  end
end

#each_pair(actual) ⇒ Enumerator #each_pair(actual) {|definition, value| ... } ⇒ Object

Iterates through the constraints and mapped values.

For each constraint defined for the contract, the contract defines a data mapping representing the object or property that the constraint will match. Calling #each_pair for an object yields the constraint and the mapped object or property for that constraint and object.

If the contract defines sanity constraints, the sanity constraints will be returned or yielded first, followed by the remaining constraints.

By default, this mapping returns the object itself; however, this can be overriden in subclasses based on the constraint options, such as matching constraints against the properties of an object rather than the object itself.

This enumerator is used internally to implement the Constraint interface for subclasses of Contract.

Overloads:

  • #each_pair(actual) ⇒ Enumerator

    Returns An enumerator for the constraints and values.

    Returns:

    • (Enumerator)

      An enumerator for the constraints and values.

  • #each_pair(actual) {|definition, value| ... } ⇒ Object

    Yield Parameters:

    • definition (Stannum::Contracts::Definition)

      Each definition from the contract or concatenated contracts.

    • value (Object)

      The mapped value for that constraint.

Parameters:

  • actual (Object)

    The object to match.

See Also:



295
296
297
298
299
300
301
302
303
# File 'lib/stannum/contracts/base.rb', line 295

def each_pair(actual)
  return enum_for(:each_pair, actual) unless block_given?

  each_constraint do |definition|
    value = definition.contract.map_value(actual, **definition.options)

    yield definition, value
  end
end

#errors_for(actual, errors: nil) ⇒ Stannum::Errors

Aggregates errors for each constraint that does not match the object.

For each defined constraint, the constraint is matched against the mapped value for that constraint and the object. If the constraint does not match the mapped value, the corresponding errors will be added to the errors object.

If the contract defines sanity constraints, the sanity constraints will be matched first. If any of the sanity constraints fail, #errors_for will immediately return the errors for the failed constraint.

Parameters:

  • actual (Object)

    The object to match.

  • errors (Stannum::Errors) (defaults to: nil)

    The errors object to append errors to. If an errors object is not given, a new errors object will be created.

Returns:

See Also:



325
326
327
328
329
330
331
332
333
334
335
336
337
# File 'lib/stannum/contracts/base.rb', line 325

def errors_for(actual, errors: nil)
  errors ||= Stannum::Errors.new

  each_pair(actual) do |definition, value|
    next if match_constraint(definition, value)

    definition.contract.add_errors_for(definition, value, errors)

    return errors if definition.sanity?
  end

  errors
end

#match(actual) ⇒ <Array(Boolean, Stannum::Errors)>

Matches and generates errors for each constraint.

For each defined constraint, the constraint is matched against the mapped value for that constraint and the object. If the constraint does not match the mapped value, the corresponding errors will be added to the errors object.

Finally, if all of the constraints match the mapped value, #match will return true and the errors object. Otherwise, #match will return false and the errors object.

If the contract defines sanity constraints, the sanity constraints will be matched first. If any of the sanity constraints fail (#matches? for the constraint returns false), then this method will immediately return false and the errors for the failed sanity constraint; and all subsequent constraints will be skipped.

Parameters:

  • actual (Object)

    The object to match.

Returns:

  • (<Array(Boolean, Stannum::Errors)>)

    the status (true or false) and the generated errors object.

See Also:



365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
# File 'lib/stannum/contracts/base.rb', line 365

def match(actual)
  status = true
  errors = Stannum::Errors.new

  each_pair(actual) do |definition, value|
    next if definition.contract.match_constraint(definition, value)

    status = false

    definition.contract.send(:add_errors_for, definition, value, errors)

    return [status, errors] if definition.sanity?
  end

  [status, errors]
end

#matches?(actual) ⇒ true, false Also known as: match?

Checks that all of the added constraints match the object.

If the contract defines sanity constraints, the sanity constraints will be matched first. If any of the sanity constraints fail (#does_not_match? for the constraint returns true), then this method will immediately return false and all subsequent constraints will be skipped.

Parameters:

  • actual (Object)

    The object to match.

Returns:

  • (true, false)

    True if all of the constraints match the given object; otherwise false. If there are no constraints, returns true.

See Also:



397
398
399
400
401
402
403
404
405
# File 'lib/stannum/contracts/base.rb', line 397

def matches?(actual)
  each_pair(actual) do |definition, value|
    unless definition.contract.match_constraint(definition, value)
      return false
    end
  end

  true
end

#negated_errors_for(actual, errors: nil) ⇒ Stannum::Errors

Aggregates errors for each constraint that matches the object.

For each defined constraint, the constraint is matched against the mapped value for that constraint and the object. If the constraint matches the mapped value, the corresponding errors will be added to the errors object.

If the contract defines sanity constraints, the sanity constraints will be matched first. If any of the sanity constraints fail, #errors_for will immediately return any errors already added to the errors object.

Parameters:

  • actual (Object)

    The object to match.

  • errors (Stannum::Errors) (defaults to: nil)

    The errors object to append errors to. If an errors object is not given, a new errors object will be created.

Returns:

See Also:



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/stannum/contracts/base.rb', line 427

def negated_errors_for(actual, errors: nil)
  errors ||= Stannum::Errors.new

  each_pair(actual) do |definition, value|
    if match_negated_constraint(definition, value)
      next unless definition.sanity?

      return errors
    end

    definition.contract.add_negated_errors_for(definition, value, errors)
  end

  errors
end

#negated_match(actual) ⇒ <Array(Boolean, Stannum::Errors)>

Matches and generates errors for each constraint.

For each defined constraint, the constraint is matched against the mapped value for that constraint and the object. If the constraint matches the mapped value, the corresponding errors will be added to the errors object.

Finally, if none of the constraints match the mapped value, #match will return true and the errors object. Otherwise, #match will return false and the errors object.

If the contract defines sanity constraints, the sanity constraints will be matched first. If any of the sanity constraints fail (#does_not_match? for the constraint returns true), then this method will immediately return true and any errors already set; and all subsequent constraints will be skipped.

Parameters:

  • actual (Object)

    The object to match.

Returns:

  • (<Array(Boolean, Stannum::Errors)>)

    the status (true or false) and the generated errors object.

See Also:



469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# File 'lib/stannum/contracts/base.rb', line 469

def negated_match(actual) # rubocop:disable Metrics/MethodLength
  status = true
  errors = Stannum::Errors.new

  each_pair(actual) do |definition, value|
    if definition.contract.match_negated_constraint(definition, value)
      next unless definition.sanity?

      return [true, errors]
    end

    status = false

    definition.contract.add_negated_errors_for(definition, value, errors)
  end

  [status, errors]
end