Class: Flows::Contract Abstract
- Inherits:
-
Object
- Object
- Flows::Contract
- 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
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 withcase
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:
- to check if data matches a contract (#check, #check!, #===, #to_proc)
- to check & slightly transform data (#transform, #transform!)
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.
Defined Under Namespace
Modules: Helpers Classes: Array, CaseEq, Compose, Either, Error, Hash, HashOf, Predicate, Transformer, Tuple
Class Method Summary collapse
Instance Method Summary collapse
-
#===(other) ⇒ Boolean
Case equality check.
-
#check(other) ⇒ Flows::Result::Ok<true>, Flows::Result::Err<String>
Checks
other
for type match. -
#check!(other) ⇒ true
abstract
Checks for type match.
-
#to_proc ⇒ Object
Allows to use contract as proc.
-
#transform(other) ⇒ Flows::Result::Ok<Object>, Flows::Result::Err<String>
Check and transform value.
-
#transform!(other) ⇒ Object
Check and transform value.
Class Method Details
Instance Method Details
#===(other) ⇒ Boolean
Case equality check.
Based on #check!
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!.
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
Checks for type match.
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_proc ⇒ Object
Allows to use contract as proc.
Based on #===.
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!.
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).
340 341 342 343 |
# File 'lib/flows/contract.rb', line 340 def transform!(other) check!(other) other end |