210 lines
8.2 KiB
Ruby
210 lines
8.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# A (partial) implementation of the functional Result type, with naming conventions based on the
|
|
# Rust implementation (https://doc.rust-lang.org/std/result/index.html)
|
|
#
|
|
# Modern Ruby 3+ destructuring and pattern matching are supported.
|
|
#
|
|
# - See "Railway Oriented Programming and the Result Class" in `ee/lib/remote_development/README.md` for details
|
|
# and example usage.
|
|
# - See `spec/lib/result_spec.rb` for detailed executable example usage.
|
|
# - See https://en.wikipedia.org/wiki/Result_type for a general description of the Result pattern.
|
|
# - See https://fsharpforfunandprofit.com/rop/ for how this can be used with Railway Oriented Programming (ROP)
|
|
# to improve design and architecture
|
|
# - See https://doc.rust-lang.org/std/result/ for the Rust implementation.
|
|
|
|
# NOTE: This class is intentionally not namespaced to allow for more concise, readable, and explicit usage.
|
|
# It it a generic reusable implementation of the Result type, and is not specific to any domain
|
|
# rubocop:disable Gitlab/NamespacedClass
|
|
class Result
|
|
# The .ok and .err factory class methods are the only way to create a Result
|
|
#
|
|
# "self.ok" corresponds to Ok(T) in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#variant.Ok
|
|
#
|
|
# @param [Object, #new] ok_value
|
|
# @return [Result]
|
|
def self.ok(ok_value)
|
|
new(ok_value: ok_value)
|
|
end
|
|
|
|
# "self.err" corresponds to Err(E) in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#variant.Err
|
|
#
|
|
# @param [Object, #new] ok_value
|
|
# @return [Result]
|
|
def self.err(err_value)
|
|
new(err_value: err_value)
|
|
end
|
|
|
|
# "#unwrap" corresponds to "unwrap" in Rust.
|
|
#
|
|
# @return [Object]
|
|
# @raise [RuntimeError] if called on an "err" Result
|
|
def unwrap
|
|
ok? ? value : raise("Called Result#unwrap on an 'err' Result")
|
|
end
|
|
|
|
# "#unwrap" corresponds to "unwrap" in Rust.
|
|
#
|
|
# @return [Object]
|
|
# @raise [RuntimeError] if called on an "ok" Result
|
|
def unwrap_err
|
|
err? ? value : raise("Called Result#unwrap_err on an 'ok' Result")
|
|
end
|
|
|
|
# The `ok?` attribute will be true if the Result was constructed with .ok, and false if it was constructed with .err
|
|
#
|
|
# "#ok?" corresponds to "is_ok" in Rust.
|
|
# @return [Boolean]
|
|
def ok?
|
|
# We don't make `@ok` an attr_reader, because we don't want to confusingly shadow the class method `.ok`
|
|
@ok
|
|
end
|
|
|
|
# The `err?` attribute will be false if the Result was constructed with .ok, and true if it was constructed with .err
|
|
# "#err?" corresponds to "is_err" in Rust.
|
|
#
|
|
# @return [Boolean]
|
|
def err?
|
|
!ok?
|
|
end
|
|
|
|
# `and_then` is a functional way to chain together operations which may succeed or have errors. It is passed
|
|
# a lambda or class (singleton) method object, and must return a Result object representing "ok"
|
|
# or "err".
|
|
#
|
|
# If the Result object it is called on is "ok", then the passed lambda or singleton method
|
|
# is called with the value contained in the Result.
|
|
#
|
|
# If the Result object it is called on is "err", then it is returned without calling the passed
|
|
# lambda or method.
|
|
#
|
|
# It only supports being passed a lambda, or a class (singleton) method object
|
|
# which responds to `call` with a single argument (arity of 1). If multiple values are needed,
|
|
# pass a hash or array. Note that passing `Proc` objects is NOT supported, even though the YARD
|
|
# annotation contains `Proc` (because the type of a lambda is also `Proc`).
|
|
#
|
|
# Passing instance methods to `and_then` is not supported, because the methods in the chain should be
|
|
# stateless "pure functions", and should not be persisting or referencing any instance state anyway.
|
|
#
|
|
# "#and_then" corresponds to "and_then" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.and_then
|
|
#
|
|
# @param [Proc, Method] lambda_or_singleton_method
|
|
# @return [Result]
|
|
# @raise [TypeError]
|
|
def and_then(lambda_or_singleton_method)
|
|
validate_lambda_or_singleton_method(lambda_or_singleton_method)
|
|
|
|
# Return/passthough the Result itself if it is an err
|
|
return self if err?
|
|
|
|
# If the Result is ok, call the lambda or singleton method with the contained value
|
|
result = lambda_or_singleton_method.call(value)
|
|
|
|
unless result.is_a?(Result)
|
|
err_msg = "'Result##{__method__}' expects a lambda or singleton method object which returns a 'Result' type " \
|
|
", but instead received '#{lambda_or_singleton_method.inspect}' which returned '#{result.class}'. " \
|
|
"Check that the previous method calls in the '#and_then' chain are correct."
|
|
raise(TypeError, err_msg)
|
|
end
|
|
|
|
result
|
|
end
|
|
|
|
# `map` is similar to `and_then`, but it is used for "single track" methods which always succeed,
|
|
# and have no possibility of returning an error (but they may still raise exceptions,
|
|
# which is unrelated to the Result handling). The passed lambda or singleton method must return
|
|
# a value, not a Result.
|
|
#
|
|
# If the Result object it is called on is "ok", then the passed lambda or singleton method
|
|
# is called with the value contained in the Result.
|
|
#
|
|
# If the Result object it is called on is "err", then it is returned without calling the passed
|
|
# lambda or method.
|
|
#
|
|
# "#map" corresponds to "map" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.map
|
|
#
|
|
# @param [Proc, Method] lambda_or_singleton_method
|
|
# @return [Result]
|
|
# @raise [TypeError]
|
|
def map(lambda_or_singleton_method)
|
|
validate_lambda_or_singleton_method(lambda_or_singleton_method)
|
|
|
|
# Return/passthrough the Result itself if it is an err
|
|
return self if err?
|
|
|
|
# If the Result is ok, call the lambda or singleton method with the contained value
|
|
mapped_value = lambda_or_singleton_method.call(value)
|
|
|
|
if mapped_value.is_a?(Result)
|
|
err_msg = "'Result##{__method__}' expects a lambda or singleton method object which returns an unwrapped " \
|
|
"value, not a 'Result', but instead received '#{lambda_or_singleton_method.inspect}' which returned " \
|
|
"a 'Result'."
|
|
raise(TypeError, err_msg)
|
|
end
|
|
|
|
# wrap the returned mapped_value in an "ok" Result.
|
|
Result.ok(mapped_value)
|
|
end
|
|
|
|
# `to_h` supports destructuring of a result object, for example: `result => { ok: }; puts ok`
|
|
#
|
|
# @return [Hash]
|
|
def to_h
|
|
ok? ? { ok: value } : { err: value }
|
|
end
|
|
|
|
# `deconstruct_keys` supports pattern matching on a Result object with a `case` statement. See specs for examples.
|
|
#
|
|
# @param [Array] keys
|
|
# @return [Hash]
|
|
# @raise [ArgumentError]
|
|
def deconstruct_keys(keys)
|
|
raise(ArgumentError, 'Use either :ok or :err for pattern matching') unless [[:ok], [:err]].include?(keys)
|
|
|
|
to_h
|
|
end
|
|
|
|
# @return [Boolean]
|
|
def ==(other)
|
|
# NOTE: The underlying `@ok` instance variable is a boolean, so we only need to check `ok?`, not `err?` too
|
|
self.class == other.class && other.ok? == ok? && other.instance_variable_get(:@value) == value
|
|
end
|
|
|
|
private
|
|
|
|
# The `value` attribute will contain either the ok_value or the err_value
|
|
attr_reader :value
|
|
|
|
def initialize(ok_value: nil, err_value: nil)
|
|
if (!ok_value.nil? && !err_value.nil?) || (ok_value.nil? && err_value.nil?)
|
|
raise(ArgumentError, 'Do not directly use private constructor, use Result.ok or Result.err')
|
|
end
|
|
|
|
@ok = err_value.nil?
|
|
@value = ok? ? ok_value : err_value
|
|
end
|
|
|
|
# @param [Proc, Method] lambda_or_singleton_method
|
|
# @return [void]
|
|
# @raise [TypeError]
|
|
def validate_lambda_or_singleton_method(lambda_or_singleton_method)
|
|
is_lambda = lambda_or_singleton_method.is_a?(Proc) && lambda_or_singleton_method.lambda?
|
|
is_singleton_method = lambda_or_singleton_method.is_a?(Method) && lambda_or_singleton_method.owner.singleton_class?
|
|
unless is_lambda || is_singleton_method
|
|
err_msg = "'Result##{__method__}' expects a lambda or singleton method object, " \
|
|
"but instead received '#{lambda_or_singleton_method.inspect}'."
|
|
raise(TypeError, err_msg)
|
|
end
|
|
|
|
arity = lambda_or_singleton_method.arity
|
|
|
|
return if arity == 1
|
|
|
|
err_msg = "'Result##{__method__}' expects a lambda or singleton method object with a single argument " \
|
|
"(arity of 1), but instead received '#{lambda_or_singleton_method.inspect}' with an arity of #{arity}."
|
|
raise(ArgumentError, err_msg)
|
|
end
|
|
end
|
|
|
|
# rubocop:enable Gitlab/NamespacedClass
|