Class: Flows::Contract Abstract

Inherits:
Object
  • Object
show all
Extended by:
Helpers
Defined in:
lib/flows/contract.rb,
lib/flows/contract/hash.rb,
lib/flows/contract/array.rb,
lib/flows/contract/error.rb,
lib/flows/contract/tuple.rb,
lib/flows/contract/either.rb,
lib/flows/contract/case_eq.rb,
lib/flows/contract/compose.rb,
lib/flows/contract/hash_of.rb,
lib/flows/contract/helpers.rb,
lib/flows/contract/predicate.rb,
lib/flows/contract/transformer.rb

Overview

This class is abstract.

A type contract based on Ruby's case equality.

Motivation

In ruby we have limited ability to express type contracts. Because of the dynamic nature of the language we cannot provide type specs or signatures for methods. We can provide type specs in a form of YARD documentation, but in this way we have no real type checking. Nothing will stop execution if type contract is violated.

Flows Contracts are designed to provide runtime type checks for critical places in your code. Let's review options we have except Flows Contracts and then define what is Flows Contract more strictly.

Recently in the Ruby community, static/runtime type checking tools started to evolve. The most advanced solution right now is Sorbet. But Sorbet solves a different problem: it provides static type checking for the whole codebase. Each method will be checked. Moreover, Sorbet is a tool like bundler or rake, not just a library.

In contrast, Flows Contracts are designed to be used in critical places only. For example to declare input and output contracts for your service objects. Or to express contracts between application layers (between Data Access Layer and Business Logic Layer for example).

As an optional feature Sorbet provides runtime checks. And if you already using Sorbet you may use it to express type contracts also. The main differences between Sorbet Runtime and Flows Contracts are:

  • Contracts relies on Ruby's case equality and set of helper Contract classes for the most common cases. Sorbet provides it's own type system and you have to learn it.
  • It may be overkill to use Sorbet for expressing contracts only. In contrast, Flows Contracts are not designed to provide contract for each method in your codebase.
  • Sorbet Runtime checks should be a bit faster then Contracts checks (because of transformations).
  • The main advantage of Flows Contracts is transformations. It allows you to slightly transform data using Contract which adds some degree of flexibility to your entities. See Tranformations section of this documentation for details.

Let's check what we have for runtime type checking in pure Ruby. To make some runtime type checks we have at least two ways:

  • methods like #is_a?, #kind_of? and #class can check if subject is an instance of a particular class
  • case equality (===) in combination with case can check different things depends on concrete class. Check this article for details.

As you may see - case equality is already a contract check. We don't need additional checkers to test if something is a String because String === x will do the job. Also lambdas is like predicates with case equality. Ranges check if subject in a range and regular expressions check for string match. The problem is that === does not provide any error messages. Second problem - === is not an object - it's just a method. Contract should be an object, it opens more ways of composition.

So, Flows Contract is a case equality check wrapped into Contract class instance with assigned error message and optional transformation logic.

Implementation

Contract is an abstract class which requires #check! method to be implemented. It provides #===, #check, #to_proc, #transform and #transform! methods for usage in different scenarios. More details in the methods' documentation.

#transform! must be overriden for types with defined transforming behaviour. By default no transformation defined - input will be equal to output. See Transformations and Transformation Laws sections of this documentation for details.

Transformations

Contract can be used in two ways:

Transformation is a way to slightly adjust input value before usage. Good example is when your method accepts both String and Symbol as a name for something, but internally name should always be a Symbol. So, contract for this case can be expressed in the following way:

Accept either String or Symbol, convert valid value to Symbol

In this way we still can use both String and Symbol instances as argument, but in the method's implementation we can be sure that we always get Symbol.

In the situation when you have to transform one or two arguments it's easier to merely rely on Ruby's methods like #to_sym, #to_s, etc. But in the cases when we talking about 3-6 arguments or nested arguments - contracts will be more convenient way to express transformations.

Transformation Laws

When you writing transformations for your contract you MUST implement it with respect to the following laws:

# let `c` be an any contract
# let `x` be an any value valid for `c`
# the following statements MUST be true

# 1. transformed value MUST match the contract:
c.check!(c.transform!(x)) == true

# 2. tranformation of transformed value MUST has no effect:
c.transform(x) == c.transform(c.transform(x))

If you violate these laws - you'll get undefined behaviour of contracts.

The meaning of these laws can be explained through Equivalence Relation. Let's use the following contract as example:

Accepts natural numbers except zero in form of String or Integer, transforms to Integer

We can define a type using a set of all possible type values. For our contract such set can be described like [1, '1', 2, '2', ...].

First law says that transformation result must not leave a type. In other words: transformation is a function from contract type to contract type.

Second law does two things:

  • split values of type into equivalence classes
  • for each equivalence class defines one and only one value which should be a transform result for any value inside the equivalent class. You may call it a tranformation fixpoint.

In our example partition will look like this: [[1, '1'], [2, '2'], ...]. Each equivalence class consists of Integer and String form of the same natural number. And Integer form is a fixpoint.

Let's review another example:

Accepts String, transform is String#strip

In this example each equivalent class is a set of stripped string and all the possible non-stripped variations. Fixpoint is a stripped string.

You may think about transformations as transformers (form cinema and animation). When transformer transforms - it's still the same guy, but in different form (first law). And fixpoint is transformer main form. We remember Megatron mostly as robot, not as truck. (second law)

If you find contract transformation too complex abstraction - you can merely not use it. Flows Contracts without transforms become just type contracts.

You MUST be extra careful with transformations and Compose. You cannot just compose any set of types and get a correct result. See Compose documentation for details

Low-level contracts

Flows provides some low-level contract classes. In almost all the cases you don't need to implement your own Contract class and you only need to compose your contract from this helper classes.

Wrappers for Ruby objects:

  • CaseEq - to wrap Ruby's case equality with error message. Automatically applied if you pass some Ruby object instead of Contract to some contract initializer. Please preserve such behaviour in custom contracts.
  • Predicate - to wrap lambda-check with error message

Composition and modification of contracts:

  • Transformer - to wrap existing contract with some transformation
  • Compose - to merge two or more contracts
  • Either - to make "or"-contract from two or more provided contracts. (String or Symbol, for example)

Contracts for common Ruby collection types:

  • Hash - restrict keys by some contract and values by another contract
  • HashOf - restrict values under particular keys by particular contracts
  • Array - restrict array elements with some contract
  • Tuple - restrict fixed-size array elements with contracts

Using these classes as is can be too verbose and ugly when building complex contracts. To address this issue Contract class has singleton methods as shortcuts and Contract.make class method as DSL:

# Accepts any string, transforms into stripped variant
strip_str = Flows::Contract.transformer(String, &:strip)

strip_str === 111
# => false

strip_str.transform!('  AAA  ')
# => 'AAA'

# Accepts positive integers
pos_int = Flows::Contract.compose(
  Integer,
  Flows::Contract.predicate('must be positive', &:positive?)
)

pos_int === 10
# => true

pos_int === -10
# => false

# Accepts numbers in String format
str_num = Flows::Contract.make do
  compose(
    String,
    case_eq(/\A\d+\z/, 'must be a number')
  )
end

# Accepts integer or number as string, transforms to integer
pos_int_from_str = transformer(either(Integer, str_num), &:to_i)

pos_int_from_str === 10
# => true

pos_int_from_str === '-10'
# => false

pos_int_from_str.transform!('10')
# => 10

pos_int_from_str.transform!(10)
# => 10

# Example of a complex contract
user_contract = Flows::Contract.make do
  hash_of(
    name: strip_str,
    email: strip_str,
    password_hash: String,
    age: pos_int_from_str,
    addresses: array(hash_of(
      country: strip_str,
      street: strip_str
    ))
  )
end

result = user_contract.transform!(
  name: '  Roman ',
  email: '[email protected]',
  password_hash: '01234567890ABCDEF',
  age: '10',
  addresses: [],
  blabla: 'blablabla' # extra field will be removed by HashOf#transform
)

result == {
  name: 'Roman',
  email: '[email protected]',
  password_hash: '01234567890ABCDEF',
  age: 10,
  addresses: []
}

All the shortcuts (without Contract.make) are available as a separate module: Helpers.

It's up to lead developer how to integrate contracts into app. You may put contract into constant and use it in the first line of your method. Or you can write some DSL. But you should avoid constructing static contracts at runtime - it's better to instantiate them during loading time (by putting it into constant, for example).

Plugin::OutputContract in combination with SharedContextPipeline will add DSL for contracts. So, you don't need to invent anything to use output contracts with shared context pipelines.

Private helper methods

Some private utility methods are defined to simplify new contract implementations:

to_contract(value) => Flows::Contract - if value is a Contract does nothing. Otherwise wraps value with CaseEq. Useful in initializers.

merge_nested_errors(description, nested_error) => String - to make an accurate multiline error messages with indentation.

Since:

  • 0.4.0

Direct Known Subclasses

Array, CaseEq, Compose, Either, Hash, HashOf, Predicate, Transformer, Tuple

Defined Under Namespace

Modules: Helpers Classes: Array, CaseEq, Compose, Either, Error, Hash, HashOf, Predicate, Transformer, Tuple

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.make(&block) ⇒ Object

Examples:

Flows::Contract.make { transformer(either(Symbol, String), &:to_sym) }

Flows::Contract.make { String }

Since:

  • 0.4.0



381
382
383
384
385
# File 'lib/flows/contract.rb', line 381

def make(&block)
  result = instance_exec(&block)

  result.is_a?(Contract) ? result : CaseEq.new(result)
end

Instance Method Details

#===(other) ⇒ Boolean

Case equality check.

Based on #check!

Examples:

Contracts and Ruby's case


case value
when contract1 then blablabla
when contract2 then blablabla2
end

Returns:

  • (Boolean)

    check result

Since:

  • 0.4.0



308
309
310
311
312
313
# File 'lib/flows/contract.rb', line 308

def ===(other)
  check!(other)
  true
rescue Flows::Contract::Error
  false
end

#check(other) ⇒ Flows::Result::Ok<true>, Flows::Result::Err<String>

Checks other for type match.

Based on #check!.

Parameters:

  • other (Object)

    object to check

Returns:

Since:

  • 0.4.0



322
323
324
325
326
327
# File 'lib/flows/contract.rb', line 322

def check(other)
  check!(other)
  Result::Ok.new(true)
rescue ::Flows::Contract::Error => err
  Result::Err.new(err.value_error)
end

#check!(other) ⇒ true

This method is abstract.

Checks for type match.

Returns:

  • (true)

    true if check succesful

Raises:



295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
# File 'lib/flows/contract.rb', line 295

class Contract
  # Case equality check.
  #
  # Based on {#check!}
  #
  # @example Contracts and Ruby's case
  #
  #     case value
  #     when contract1 then blablabla
  #     when contract2 then blablabla2
  #     end
  #
  # @return [Boolean] check result
  def ===(other)
    check!(other)
    true
  rescue Flows::Contract::Error
    false
  end

  # Checks `other` for type match.
  #
  # Based on {#check!}.
  #
  # @param other [Object] object to check
  # @return [Flows::Result::Ok<true>] if check successful
  # @return [Flows::Result::Err<String>] if check failed
  def check(other)
    check!(other)
    Result::Ok.new(true)
  rescue ::Flows::Contract::Error => err
    Result::Err.new(err.value_error)
  end

  # Check and transform value.
  #
  # Override this method to implement type transform behaviour.
  #
  # If contract is built from other contracts -
  # all internal contracts must be called via {#transform}.
  #
  # You must obey Transformation Laws (see {Contract} class documentation).
  #
  # @return [Object] successful result with value after transformation
  # @raise [Flows::Contract::Error] if check failed
  def transform!(other)
    check!(other)
    other
  end

  # Check and transform value.
  #
  # Based on {#transform!}.
  #
  # @return [Flows::Result::Ok<Object>] successful result with value after type transform
  # @return [Flows::Result::Err<String>] failure result with error message
  def transform(other)
    Result::Ok.new(transform!(other))
  rescue ::Flows::Contract::Error => err
    Result::Err.new(err.value_error)
  end

  # Allows to use contract as proc.
  #
  # Based on {#===}.
  #
  # @example Check all elements in an array
  #     pos_num = Flows::Contract::Predicate.new 'must be positive' do |x|
  #       x > 0
  #     end
  #
  #     [1, 2, 3].all?(&pos_num)
  #     # => true
  def to_proc
    proc do |obj|
      self === obj # rubocop:disable Style/CaseEquality
    end
  end

  class << self
    include Helpers

    # @example
    #     Flows::Contract.make { transformer(either(Symbol, String), &:to_sym) }
    #
    #     Flows::Contract.make { String }
    def make(&block)
      result = instance_exec(&block)

      result.is_a?(Contract) ? result : CaseEq.new(result)
    end
  end

  private

  # :reek:UtilityFunction
  def to_contract(value)
    value.is_a?(::Flows::Contract) ? value : CaseEq.new(value)
  end

  # :reek:UtilityFunction
  def merge_nested_errors(description, nested_errors)
    shifted = nested_errors.split("\n").map { |str| "    #{str}" }.join("\n")

    "#{description}\n#{shifted}"
  end
end

#to_procObject

Allows to use contract as proc.

Based on #===.

Examples:

Check all elements in an array

pos_num = Flows::Contract::Predicate.new 'must be positive' do |x|
  x > 0
end

[1, 2, 3].all?(&pos_num)
# => true

Since:

  • 0.4.0



368
369
370
371
372
# File 'lib/flows/contract.rb', line 368

def to_proc
  proc do |obj|
    self === obj # rubocop:disable Style/CaseEquality
  end
end

#transform(other) ⇒ Flows::Result::Ok<Object>, Flows::Result::Err<String>

Check and transform value.

Based on #transform!.

Returns:

Since:

  • 0.4.0



351
352
353
354
355
# File 'lib/flows/contract.rb', line 351

def transform(other)
  Result::Ok.new(transform!(other))
rescue ::Flows::Contract::Error => err
  Result::Err.new(err.value_error)
end

#transform!(other) ⇒ Object

Check and transform value.

Override this method to implement type transform behaviour.

If contract is built from other contracts - all internal contracts must be called via #transform.

You must obey Transformation Laws (see Flows::Contract class documentation).

Returns:

  • (Object)

    successful result with value after transformation

Raises:

Since:

  • 0.4.0



340
341
342
343
# File 'lib/flows/contract.rb', line 340

def transform!(other)
  check!(other)
  other
end