Results

Gem Version Build Status Dependency Status Code Climate Coverage Status

A functional combinator of results which are either Good or Bad.

Inspired by the ScalaUtils library's Or and Every classes, whose APIs are documented here and here.

Table of Contents

Usage

Basic validation

By default, Results will transform an ArgumentError into a Bad, allowing built-in numeric conversions to work directly as validations.

def parseAge(str)
  Results.new(str) { |v| Integer(v) }
end

parseAge('1')   # => #<struct Results::Good value=1>
parseAge('abc') # => #<struct Results::Bad why=[#<struct Results::Because error="invalid value for integer", input="abc">]>

Chained filters and validations

Once you have a Good or Bad, you can chain additional boolean filters using #when and #when_not.

def parseAge21To45(str)
  # Syntax workaround due to no chaining on blocks - Open to suggestions!
  _ = parseAge(str)
  _ = _.when    ('under 45') { |v| v < 45 }
  _ = _.when_not('under 21') { |v| v < 21 }
end

parseAge21To45('29') # => #<struct Results::Good value=29>
parseAge21To45('65') # => #<struct Results::Bad why=[#<struct Results::Because error="not under 45", input=65>]>
parseAge21To45('1')  # => #<struct Results::Bad why=[#<struct Results::Because error="under 21", input=1>]>

Want to save your favorite filters? You can use the provided class Filter, or any object with the same duck-type, meaning it responds to #call and #message.

# You can use the provided class Filter
under_45 = Results::Filter.new('under 45') { |v| v < 45 }

# ...or do something funky like this...
under_21 = lambda { |v| v < 21 }.tap { |l| l.define_singleton_method(:message) { 'under 21' } }

# Both work the same way
parseAge('65')
  .when(under_45)
  .when_not(under_21) # => #<struct Results::Bad why=[#<struct Results::Because error="not under 45", input=65>]>

You can also chain validation functions (returning Good or Bad instead of Boolean) using #validate.

def parseAgeRange(str)
  parseAge(str).validate do |v|
    case v
    when 21...45 then Results::Good.new(v)
    else              Results::Bad.new('not between 21 and 45', v)
    end
  end
end

parseAgeRange('29') # => #<struct Results::Good value=29>
parseAgeRange('65') # => #<struct Results::Bad why=[#<struct Results::Because error="not between 21 and 45", input=65>]>

For convenience, the #when and #when_not methods can also accept a lambda for the error message, to format the error message based on the input value.

parseAge('65').when(lambda { |v| "#{v} is not under 45" }) { |v| v < 45 } 
# => #<struct Results::Bad why=[#<struct Results::Because error="65 is not under 45", input=65>]>

In a similar vein, if you already have a Filter or compatible duck-type (see above), it's easy turn it into a basic validation function returning Good or Bad via convenience functions Results.when and Results.when_not.

Results.when_not(under_21).call(16) 
# => #<struct Results::Bad why=[#<struct Results::Because error="under 21", input=16>]>

Note that this is equivalent to:

Results.new(16).when_not(under_21)

The benefit of Results.when is for cases where the value (here, 16) is not yet known.

Experience has shown that many filters that are written are simply predicates called on value objects, such as Numeric#zero? or String#empty? or even Object#nil?.

For these cases, you can use the convenience function Results.predicate.

# validates non-nil, non-empty
def valid?(str)
  Results.new(str)
    .when_not(Results.predicate :nil?)
    .when_not(Results.predicate :empty?)
end

Or even simpler, just pass the symbol of the predicate name directly to #when or #when_not.

# same as above
def valid_short?(str)
  Results.new(str).when_not(:nil?).when_not(:empty?)
end

Accumulating multiple bad results

So, now the interesting parts (Yes, the earlier sections were a bit slow, but it picks up a bit here):

Multiple filters and validations of a single input

The way we've done things so far, even if you chained multiple filters and validations together, if more than one would fail for some input, you would only see the first Bad, and none of the the later filters would be run.

Now, instead, we're going to accumulate all the failures for a single input.

One simple way is to intersperse the #and method between your chained #when calls.

# Good still works as before
Results.new(0).when(:integer?).and.when(:zero?)    # => #<struct Results::Good value=0>

# Bad accumulates multiple failures
Results.new(1.23).when(:integer?).and.when(:zero?) # => #<struct Results::Bad why=[
                                                   #      #<struct Results::Because error="not integer", input=1.23>,
                                                   #      #<struct Results::Because error="not zero", input=1.23>]>

You can also call #when_all and#when_all_not with a collection of filters.

filters = [:integer?, :zero?, Results::Filter.new('greater than 2') { |n| n > 2 }]
r = Results.new(1.23).when_all(filters) # => #<struct Results::Bad why=[
                                        #      #<struct Results::Because error="not integer", input=1.23>,
                                        #      #<struct Results::Because error="not zero", input=1.23>,
                                        #      #<struct Results::Because error="not greater than 2", input=1.23>]>

For a collection of validation functions, you can use #validate_all in a similar fashion.

Combine results of multiple inputs

If you have two results, the simplest way to combine them is with #zip. If both results are good, it returns a Good containing an array of both values. However, if any results are bad, it returns a Bad containing all the failures.

good = Results::Good.new(1)
bad1 = Results::Bad.new('not nonzero', 0)
bad2 = Results::Bad.new('not integer', 1.23)

good.zip(good)           # => #<struct Results::Good value=[1, 1]>

good.zip(bad1).zip(bad2) # => #<struct Results::Bad why=[
                         #      #<struct Results::Because error="not nonzero", input=0>,
                         #      #<struct Results::Because error="not integer", input=1.23>]>

If you have a collection of results, you can combine them with Results.combine. If all results are good, it returns a single Good containing a collection of all the values. However, if any results are bad, it returns a single Bad containing all the failures.

all_good_results = [good, good, good]
some_bad_results = [bad1, good, bad2]

Results.combine(all_good_results) # => #<struct Results::Good value=[1, 1, 1]>

Results.combine(some_bad_results) # => #<struct Results::Bad why=[
                                  #      #<struct Results::Because error="not nonzero", input=0>,
                                  #      #<struct Results::Because error="not integer", input=1.23>]>

NOTE: this section is under construction...

TODO

  1. Define api
  2. Implement
  3. Document using yard
  4. Release 0.9.0 and solicit comments
  5. Incorporate suggested changes
  6. Release 1.0.0
  7. Potentially incorporate as a depedendency into Rumonade