Files
gitlab-foss/lib/gitlab/fp/result.rb
2025-06-03 18:07:50 +00:00

390 lines
17 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/gitlab/fp/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.
module Gitlab
# noinspection RubyClassModuleNamingConvention -- JetBrains is changing this to allow shorter names
module Fp
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]
# noinspection MissingYardParamTag -- RubyMine does not recognize "duck type" Types
# (https://rubydoc.info/gems/yard/file/docs/Tags.md#duck-types). This has been
# reported to JetBrains - issue link pending
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] err_value
# @return [Result]
# noinspection MissingYardParamTag -- RubyMine does not recognize "duck type" Types
# (https://rubydoc.info/gems/yard/file/docs/Tags.md#duck-types). This has been
# reported to JetBrains - issue link pending
def self.err(err_value)
new(err_value: err_value)
end
# @param [Object, nil] ok_value
# @param [Object, nil] err_value
# @return [Object]
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
private :initialize
# "#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 is 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 is 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(callee: lambda_or_singleton_method, invoking_method: __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(callee: lambda_or_singleton_method, invoking_method: __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
# `map_err` is the inverse of `map`. It behaves identically, but it only processes `err` values
# instead of `ok` values.
#
# "#map_err" corresponds to "map_err" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.map_err
#
# @param [Proc, Method] lambda_or_singleton_method
# @return [Result]
# @raise [TypeError]
def map_err(lambda_or_singleton_method)
validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__)
# Return/passthrough the Result itself if it is an ok
return self if ok?
# If the Result is err, 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 "err" Result.
Result.err(mapped_value)
end
# `inspect_ok` is similar to `map`, becuase it receives the wrapped `ok` value, but it does not allow modification
# of the value like `map`. The original result is always returned from `inspect_ok`.
#
# The passed lambda or singleton method must return, `nil`, to enforce the fact that the return value is ignored,
# and the original Result is always returned. This corresponds to the `void` type in YARD/RBS type annotations,
# and the `unit` type in Rust (https://doc.rust-lang.org/std/primitive.unit.html).
#
# If the passed method does not return `nil`, an error will be raised.
#
# "#inspect_ok" corresponds to "inspect" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.inspect
#
# If enforce_immutability is true, the passed method will not be allowed to mutate the value passed to it.
#
# Note that we could not call this method `inspect` to match the Rust Result.inspect function, because that
# would conflict with the Kernel#inspect method in Ruby.
#
# @param [Proc, Method] lambda_or_singleton_method
# @param [Boolean] enforce_immutability enforces immutability of the value passed to lambda_or_singleton_method
# @return [Result]
# @raise [TypeError]
def inspect_ok(lambda_or_singleton_method, enforce_immutability: true)
validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __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
call_and_optionally_enforce_value_is_not_mutated(
callee: lambda_or_singleton_method,
value: value,
invoking_method: __method__,
enforce_immutability: enforce_immutability
)
# Return/passthrough the original Result
self
end
# `inspect_err` is the inverse of `inspect_ok`. It behaves identically, but it only processes `err` values
# instead of `ok` values.
#
# The passed lambda or singleton method must return, `nil`, to enforce the fact that the return value is ignored,
# and the original Result is always returned. This corresponds to the `void` type in YARD/RBS type annotations,
# and the `unit` type in Rust (https://doc.rust-lang.org/std/primitive.unit.html).
#
# If the passed method does not return `nil`, an error will be raised.
#
# If enforce_immutablity is true, the passed method will not be allowed to mutate the value passed to it.
#
# "#inspect_err" corresponds to "inspect_err" in Rust: https://doc.rust-lang.org/std/result/enum.Result.html#method.inspect_err
#
# @param [Proc, Method] lambda_or_singleton_method
# @param [Boolean] enforce_immutability enforces immutability of the value passed to lambda_or_singleton_method
# @return [Result]
# @raise [TypeError]
def inspect_err(lambda_or_singleton_method, enforce_immutability: true)
validate_lambda_or_singleton_method(callee: lambda_or_singleton_method, invoking_method: __method__)
# Return/passthrough the Result itself if it is an ok
return self if ok?
# If the Result is err, call the lambda or singleton method with the contained value
call_and_optionally_enforce_value_is_not_mutated(
callee: lambda_or_singleton_method,
value: value,
invoking_method: __method__,
enforce_immutability: enforce_immutability
)
# Return/passthrough the original Result
self
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
# @param [Result] other
# @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
#
# @return [Object]
# noinspection RubyMismatchedReturnType
def value # rubocop:disable Style/TrivialAccessors -- We are not using attr_reader here, so we can avoid nilability type errors in RubyMine
# TODO: We are not using attr_reader here, so we can avoid nilability type errors in RubyMine.
# This will be reported to JetBrains and tracked on
# https://handbook.gitlab.com/handbook/tools-and-tips/editors-and-ides/jetbrains-ides/tracked-jetbrains-issues/,
# this comment should then be updated with the issue link on that page.
# Note that we don't use `noinspection` to suppress this error because there's several instances where
# this error leaks to other classes through the call stack.
@value
end
# @param [Proc, Method] callee
# @param [Symbol] invoking_method
# @return [void]
# @raise [TypeError]
def validate_lambda_or_singleton_method(callee:, invoking_method:)
is_lambda = callee.is_a?(Proc) && callee.lambda?
is_singleton_method =
callee.is_a?(Method) && callee.owner.singleton_class?
unless is_lambda || is_singleton_method
err_msg = "Result##{invoking_method} expects a lambda or singleton method object, " \
"but instead received '#{callee.inspect}'."
raise(TypeError, err_msg)
end
arity = callee.arity
return if arity == 1
return if arity == -1 && callee.source_location[0].include?('rspec')
err_msg = "Result##{invoking_method} expects a lambda or singleton method object with a single argument " \
"(arity of 1), but instead received '#{callee.inspect}' with an arity of #{arity}."
raise(ArgumentError, err_msg)
end
# @param [Proc, Method] callee
# @param [Object] value
# @param [Symbol] invoking_method
# @param [Boolean] enforce_immutability enforces immutability of the value passed to lambda_or_singleton_method
# @return [void]
# @raise [RuntimeError]
def call_and_optionally_enforce_value_is_not_mutated(callee:, value:, invoking_method:, enforce_immutability:)
return_value_from_call =
if enforce_immutability
# If enforce_immutability is true, deep clone and freeze the value before passing it,
# so that the callee can't mutate the original value
begin
callee.call(deep_clone_and_freeze(value))
rescue FrozenError => e
err_msg = "ERROR: #{callee} must not modify the passed value argument, because it was invoked via " \
"Result##{invoking_method}. Ensure that no side effects are being performed which modify any " \
"properties of nested objects, such as lazy memoization. " \
"Alternately, you can pass 'enforce_immutability: false' to Result##{invoking_method}."
raise e.exception("#{e.message}. #{err_msg}")
end
else
callee.call(value)
end
validate_return_value_is_void(return_value: return_value_from_call, invoking_method: invoking_method)
end
# @param [Object] object
# @return [Object]
def deep_clone_and_freeze(object)
case object
when Array
object.map { |entry| deep_clone_and_freeze(entry) }.freeze
when Hash
object.transform_values { |entry| deep_clone_and_freeze(entry) }.freeze
else
object.clone(freeze: true)
end
end
# @param [Proc, Method] return_value
# @param [Symbol] invoking_method
# @return [void]
# @raise [TypeError]
def validate_return_value_is_void(return_value:, invoking_method:)
return if return_value.nil?
err_msg = "The method passed to Result##{invoking_method} must always return 'nil' (void). This enforces " \
"that the return value is never used or modified. The existing 'Result' object is always passed along the " \
"chain unchanged. The return value received was '#{return_value.inspect}' instead of 'nil'."
raise(TypeError, err_msg)
end
end
end
end