diff --git a/lib/upshot/option.ex b/lib/upshot/option.ex index 7d47afa..3aac944 100644 --- a/lib/upshot/option.ex +++ b/lib/upshot/option.ex @@ -2,68 +2,155 @@ defmodule Upshot.Option do alias Upshot.{ExpectError, Option.Proto, Result, UnwrapError} @moduledoc """ - A protocol for interacting with optional types. + A standard interface for working with optional types. + + Options are values which either contain a result (called a "some"), or a + non-value (called a "none"). A good example of an option is the result of + `Map.fetch/2` - either an ok tuple or error atom - in fact the option protocol + is implemented for `Tuple` and `Atom` for exactly this reason. + + In order to use module with your own types, you need to implement the + `Upshot.Option.Proto` protocol. """ @type t :: Proto.t() @type map_fn :: (any -> t) @type predicate_fn :: (any -> boolean) + @callback some(any) :: t + @callback none() :: t + @doc """ Create a new option from value. + + ## Example + + iex> some("Marty") + {:ok, "Marty"} """ @spec some(any) :: t def some(value), do: {:ok, value} @doc """ Create a new option. + + ## Example + + iex> none() + :error """ @spec none :: t def none, do: :error @doc """ Does the option contain a value? + + ## Examples + + iex> some("Marty") + ...> |> some?() + true + + iex> none() + ...> |> some?() + false """ - @spec some?(t) :: boolean - defdelegate some?(option), to: Proto + @spec some?(t) :: boolean | no_return + def some?(option) do + assert_valid!(option) + + Proto.some?(option) + end @doc """ - Does the option contain no value? + Is the option a none? + + ## Examples + + iex> none() + ...> |> none?() + true + + iex> some("Marty") + ...> |> none?() + false """ - @spec none?(t) :: boolean - defdelegate none?(option), to: Proto + @spec none?(t) :: boolean | no_return + def none?(option) do + assert_valid!(option) + + Proto.none?(option) + end @doc """ - Convert an option into a result. + Convert an option into an `Upshot.Result`. If the option is some then it will contain the some, otherwise it will use the provided error value. + + ## Examples + + iex> some("Marty") + ...> |> ok_or("Doc") + {:ok, "Marty"} + + iex> none() + ...> |> ok_or("Doc") + {:error, "Doc"} """ - @spec ok_or(t, any) :: Result.t() - def ok_or(option, error) do - if Proto.some?(option), - do: option, - else: {:error, error} + @spec ok_or(t, any) :: Result.t() | no_return + def ok_or(option, error_value) do + assert_valid!(option) + + case Proto.unwrap(option) do + {:ok, value} -> {:ok, value} + :error -> {:error, error_value} + end end @doc """ Convert an option into a result mapping a some value into an ok value, and none into an error using the provided function. + + ## Examples + + iex> some("Marty") + ...> |> ok_or_else(fn -> "Doc" end) + {:ok, "Marty"} + + iex> none() + ...> |> ok_or_else(fn -> "Doc" end) + {:error, "Doc"} """ - @spec ok_or_else(t, (() -> any)) :: t + @spec ok_or_else(t, (() -> any)) :: t | no_return def ok_or_else(option, err_callback) when is_function(err_callback, 0) do - if Proto.some?(option), - do: option, - else: {:error, safe_callback(err_callback, [])} + assert_valid!(option) + + if Proto.some?(option) do + option + else + {:error, apply(err_callback, [])} + end end @doc """ A logical and. - Returns none if the option is none, otherwise the other option. + Returns none if the lhs option is none, otherwise the rhs option. + + ## Examples + + iex> and_(some("Doc"), some("Marty")) + {:ok, "Marty"} + + iex> and_(none(), some("Marty")) + :error """ - @spec and_(t, t) :: t + @spec and_(t, t) :: t | no_return def and_(lhs, rhs) do + assert_valid!(lhs) + assert_valid!(rhs) + if Proto.none?(lhs), do: lhs, else: rhs @@ -72,20 +159,58 @@ defmodule Upshot.Option do @doc """ Returns none, if the option is none, otherwise ok_callback with the wrapped value and returns the result. + + Useful for building combinators. + + ## Example + + iex> square = fn i -> {:ok, i * i} end + ...> some(2) + ...> |> and_then(square) + ...> |> and_then(square) + ...> |> and_then(square) + {:ok, 256} + + iex> square = fn i -> {:ok, i * i} end + ...> some(2) + ...> |> and_then(fn _ -> :error end) + ...> |> and_then(square) + :error + """ - @spec and_then(t, map_fn) :: t + @spec and_then(t, map_fn) :: t | no_return def and_then(option, ok_callback) when is_function(ok_callback, 1) do - if Proto.none?(option), - do: option, - else: safe_callback(ok_callback, [unwrap(option)]) + assert_valid!(option) + + case Proto.unwrap(option) do + :error -> + option + + {:ok, value} -> + ok_callback + |> apply([value]) + |> assert_valid!() + end end @doc """ Returns none if the option is none, otherwise calls predicate with the wrapped value returns the wrapped value if the predicate returns true, otherwise none. + + ## Examples + + iex> some(4) + ...> |> filter(&(rem(&1, 2) == 0)) + {:ok, 4} + + iex> some(3) + ...> |> filter(&(rem(&1, 2) == 0)) + :error """ - @spec filter(t, predicate_fn) :: t + @spec filter(t, predicate_fn) :: t | no_return def filter(option, predicate) do + assert_valid!(option) + case Proto.unwrap(option) do {:ok, value} -> if apply(predicate, [value]), @@ -100,9 +225,23 @@ defmodule Upshot.Option do @doc """ Returns the option if it contains a value, otherwise returns the alternative option. + + ## Examples + + iex> or_(some("Marty"), some("Doc")) + {:ok, "Marty"} + + iex> or_(none(), some("Doc")) + {:ok, "Doc"} + + iex> or_(none(), none()) + :error """ - @spec or_(t, t) :: t + @spec or_(t, t) :: t | no_return def or_(lhs, rhs) do + assert_valid!(lhs) + assert_valid!(rhs) + if Proto.some?(lhs), do: lhs, else: rhs @@ -111,21 +250,52 @@ defmodule Upshot.Option do @doc """ Returns the option if it contains a value, otherwise calls err_callback and returns the resulting option. + + ## Examples + + iex> some("Marty") + ...> |> or_else(fn -> some("Doc") end) + {:ok, "Marty"} + + iex> none() + ...> |> or_else(fn -> some("Doc") end) + {:ok, "Doc"} """ - @spec or_else(t, (() -> t)) :: t + @spec or_else(t, (() -> t)) :: t | no_return def or_else(option, err_callback) when is_function(err_callback, 0) do + assert_valid!(option) + if Proto.some?(option) do option else - apply(err_callback, []) + err_callback + |> apply([]) + |> assert_valid!() end end @doc """ Returns some if exactly one of the options are some, otherwise none. + + ## Examples + + iex> xor(some("Marty"), none()) + {:ok, "Marty"} + + iex> xor(none(), some("Doc")) + {:ok, "Doc"} + + iex> xor(some("Marty"), some("Doc")) + :error + + iex> xor(none(), none()) + :error """ - @spec xor(t, t) :: t + @spec xor(t, t) :: t | no_return def xor(lhs, rhs) do + assert_valid!(lhs) + assert_valid!(rhs) + cond do Proto.some?(lhs) && Proto.none?(rhs) -> lhs Proto.none?(lhs) && Proto.some?(rhs) -> rhs @@ -137,9 +307,23 @@ defmodule Upshot.Option do Zips the two options together. If both options are some, returns a some containing a tuple of both values, otherwise none. + + ## Examples + + iex> zip(some("Marty"), some("Doc")) + {:ok, {"Marty", "Doc"}} + + iex> zip(some("Marty"), none()) + :error + + iex> zip(none(), some("Doc")) + :error """ - @spec zip(t, t) :: t + @spec zip(t, t) :: t | no_return def zip(lhs, rhs) do + assert_valid!(lhs) + assert_valid!(rhs) + with {:ok, lhs} <- Proto.unwrap(lhs), {:ok, rhs} <- Proto.unwrap(rhs) do {:ok, {lhs, rhs}} @@ -152,9 +336,21 @@ defmodule Upshot.Option do Unwraps the option and returns the value. Raises an `ExpectError` on failure with `message`. + + ## Examples + + iex> some("Marty") + ...> |> expect!("the Delorean has no driver!") + "Marty" + + iex> none() + ...> |> expect!("the Delorean has no driver!") + ** (Upshot.ExpectError) the Delorean has no driver! """ - @spec expect(t, String.t()) :: any | no_return - def expect(option, message) when is_binary(message) do + @spec expect!(t, String.t()) :: any | no_return + def expect!(option, message) when is_binary(message) do + assert_valid!(option) + case Proto.unwrap(option) do {:ok, value} -> value :error -> raise ExpectError, message @@ -165,13 +361,25 @@ defmodule Upshot.Option do Unwraps the option presuming it's none. Raises an `ExpectError` when the option is some with `message`. + + ## Examples + + iex> some("Marty") + ...> |> expect_none!("you should be in 1955!") + ** (Upshot.ExpectError) you should be in 1955! + + iex> none() + ...> |> expect_none!("you should be in 1955!") + :error """ - @spec expect_none(t, String.t()) :: t | no_return - def expect_none(option, message) when is_binary(message) do + @spec expect_none!(t, String.t()) :: t | no_return + def expect_none!(option, message) when is_binary(message) do + assert_valid!(option) + if Proto.some?(option) do raise ExpectError, message else - none() + option end end @@ -179,12 +387,24 @@ defmodule Upshot.Option do Unwraps the option and returns the value. Raises an `UnwrapError` on failure. + + ## Examples + + iex> some("Marty") + ...> |> unwrap!() + "Marty" + + iex> none() + ...> |> unwrap!() + ** (Upshot.UnwrapError) Attempt to unwrap a none. """ - @spec unwrap(t) :: any | no_return - def unwrap(option) do + @spec unwrap!(t) :: any | no_return + def unwrap!(option) do + assert_valid!(option) + case Proto.unwrap(option) do {:ok, value} -> value - :error -> raise UnwrapError, "Attempt to unwrap a none" + :error -> raise UnwrapError, "Attempt to unwrap a none." end end @@ -192,33 +412,50 @@ defmodule Upshot.Option do Unwraps an option and returns a none. Raises an `UnwrapError` on failure. + + ## Examples + + iex> some("Marty") + ...> |> unwrap_none!() + ** (Upshot.UnwrapError) Expected none, found `{:ok, "Marty"}`. """ - @spec unwrap_none(t) :: t | no_return - def unwrap_none(option) do + @spec unwrap_none!(t) :: t | no_return + def unwrap_none!(option) do + assert_valid!(option) + if Proto.none?(option) do option else - raise UnwrapError, "Attempt to unwrap a some" + raise UnwrapError, "Expected none, found `#{inspect(option)}`." end end @doc """ Unwraps an option and returns it's contained value or a default. + + ## Examples + + iex> some("Marty") + ...> |> unwrap_or_default("Doc") + "Marty" + + iex> none() + ...> |> unwrap_or_default("Doc") + "Doc" """ - @spec unwrap_or_default(t, any) :: any + @spec unwrap_or_default(t, any) :: any | no_return def unwrap_or_default(option, default) do + assert_valid!(option) + case Proto.unwrap(option) do {:ok, value} -> value :error -> default end end - # Safely execute a mapping function, catching an exceptions and converting - # them into a result. - @spec safe_callback((... -> any()), [any()]) :: any() - defp safe_callback(callback, args) when is_function(callback) when is_list(args) do - apply(callback, args) - rescue - error -> {:error, error} + defp assert_valid!(option) do + if Proto.valid?(option), + do: option, + else: raise(ArgumentError, "Expected option, but got #{inspect(option)}") end end diff --git a/lib/upshot/option/proto.ex b/lib/upshot/option/proto.ex index 1145a3d..c6e8062 100644 --- a/lib/upshot/option/proto.ex +++ b/lib/upshot/option/proto.ex @@ -3,14 +3,59 @@ defprotocol Upshot.Option.Proto do A protocol for interacting with optional types. """ - @fallback_to_any true + @doc """ + Used to check if the option contains a value. + ## Example + + iex> some?({:ok, "Marty"}) + true + """ @spec some?(t) :: boolean def some?(_option) + @doc """ + Used to check if the option is empty. + + ## Example + + iex> none?(:error) + true + """ @spec none?(t) :: boolean def none?(_option) - @spec unwrap(t) :: {:ok, any} | :error + @doc """ + Converts the option type into an ok tuple, or error atom. + + This is used internally by the `Option` module for combinators, etc. + + ## Example + + iex> OptionExample.some("Marty") + ...> |> unwrap() + {:ok, "Marty"} + """ + @spec unwrap(t) :: {:ok, any} | :error | no_return def unwrap(_option) + + @doc """ + Validates that the provided value is a valid option. + + Used internally by the `Option` module to ensure that arguments and callback + results are valid options. + + Return true if the option is valid, false otherwise. + + ## Examples + + iex> valid?({:ok, "Marty"}) + true + + iex> valid?({:error, "Doc Brown"}) + false + + """ + @spec valid?(t) :: boolean + def valid?(_option) end diff --git a/lib/upshot/option/proto_any.ex b/lib/upshot/option/proto_any.ex deleted file mode 100644 index e63450d..0000000 --- a/lib/upshot/option/proto_any.ex +++ /dev/null @@ -1,26 +0,0 @@ -defimpl Upshot.Option.Proto, for: Any do - @moduledoc """ - Implements the option protocol for arbitrary terms. - """ - - @doc """ - Returns `true` for `{:ok, value}` tuples, otherwise `false`. - """ - @spec some?(any) :: boolean - def some?({:ok, _value}), do: true - def some?(_option), do: false - - @doc """ - Returns `false` for `{:ok, value}` tuples, otherwise `true`. - """ - @spec none?(any) :: boolean - def none?({:ok, _value}), do: false - def none?(_option), do: true - - @doc """ - Returns the internal value of `{:ok, value}` or `:error`. - """ - @spec unwrap(any) :: {:ok, any} | :error - def unwrap({:ok, value}), do: {:ok, value} - def unwrap(_option), do: :error -end diff --git a/lib/upshot/option/proto_atom.ex b/lib/upshot/option/proto_atom.ex new file mode 100644 index 0000000..e332a0c --- /dev/null +++ b/lib/upshot/option/proto_atom.ex @@ -0,0 +1,55 @@ +defimpl Upshot.Option.Proto, for: Atom do + @moduledoc """ + Implements the option protocol for atoms. + """ + + @doc """ + Returns `false`. + """ + @spec some?(atom) :: false + def some?(_), do: false + + @doc """ + Returns `true` if the atom is `:error` or `nil`, otherwise `false`. + + ## Examples + + iex> none?(:error) + true + + iex> none?(nil) + true + + iex> none?(:marty) + false + """ + @spec none?(atom) :: boolean + def none?(:error), do: true + def none?(nil), do: true + def none?(_), do: false + + @doc """ + Returns `:error`. + """ + @spec unwrap(atom) :: :error + def unwrap(_), do: :error + + @doc """ + Returns `true` for `:error` and `nil` atoms, otherwise `false`. + + ## Examples + + iex> valid?(:error) + true + + iex> valid?(nil) + true + + iex> valid?(:marty) + false + """ + @spec valid?(atom) :: boolean + def valid?(:error), do: true + def valid?(nil), do: true + def valid?(_), do: false +end diff --git a/lib/upshot/option/proto_tuple.ex b/lib/upshot/option/proto_tuple.ex new file mode 100644 index 0000000..48f6478 --- /dev/null +++ b/lib/upshot/option/proto_tuple.ex @@ -0,0 +1,57 @@ +defimpl Upshot.Option.Proto, for: Tuple do + @moduledoc """ + Implements the option protocol for tuples allowing `{:ok, any}` to be a valid + option. + """ + + @doc """ + Returns `true` for `{:ok, any}`, otherwise `false`. + + ## Examples + + iex> some?({:ok, "Marty"}) + true + + iex> some?({"Marty", "McFly"}) + false + """ + @spec some?(tuple) :: boolean + def some?({:ok, _}), do: true + def some?(_), do: false + + @doc """ + Returns `false` for all tuples. + """ + @spec none?(tuple) :: false + def none?(_), do: false + + @doc """ + Passes through `{:ok, any}` tuples, otherwise `:error`. + + ## Examples + + iex> unwrap({:ok, "Doc"}) + {:ok, "Doc"} + + iex> unwrap({"Marty", "McFly"}) + :error + """ + @spec unwrap(tuple) :: {:ok, any} | :error + def unwrap({:ok, _} = value), do: value + def unwrap(_), do: :error + + @doc """ + Validates that the tuple is in the form `{:ok, any}`. + + ## Examples + + iex> valid?({:ok, "Doc"}) + true + + iex> valid?({"Marty", "McFly"}) + false + """ + @spec valid?(tuple) :: boolean + def valid?({:ok, _}), do: true + def valid?(_), do: false +end diff --git a/lib/upshot/result.ex b/lib/upshot/result.ex index 1609e25..7885a20 100644 --- a/lib/upshot/result.ex +++ b/lib/upshot/result.ex @@ -6,7 +6,10 @@ defmodule Upshot.Result do """ @type t :: Proto.t() - @type map_fn :: (any -> {:ok, any} | {:error, any}) + @type map_fn :: (any -> t) + + @callback ok(any) :: t + @callback error(any) :: t @doc """ Create a new result with an ok value. @@ -21,10 +24,22 @@ defmodule Upshot.Result do def error(reason), do: {:error, reason} @doc """ - Convert a result into an option. + Convert a result into an option by discarding any error values. + + ## Example + + iex> ok("Marty") + ...> |> to_option() + {:ok, "Marty"} + + iex> error("Doc Brown") + ...> |> to_option() + :error """ - @spec to_option(t) :: Option.t() + @spec to_option(t) :: Option.t() | no_return def to_option(result) do + assert_valid!(result) + case Proto.unwrap(result) do {:ok, value} -> {:ok, value} _error -> :error @@ -32,78 +47,204 @@ defmodule Upshot.Result do end @doc """ - Is the result okay? + Does the result contain an ok? + + ## Examples + + iex> ok("Marty") |> ok?() + true + + iex> error("Doc") |> ok?() + false """ - @spec ok?(t) :: boolean - defdelegate ok?(result), to: Proto + @spec ok?(t) :: boolean | no_return + def ok?(result) do + assert_valid!(result) + + Proto.ok?(result) + end @doc """ Is the result an error? + + ## Examples + + iex> ok("Marty") |> error?() + false + + iex> error("Doc") |> error?() + true """ - @spec error?(t) :: boolean - defdelegate error?(result), to: Proto + @spec error?(t) :: boolean | no_return + def error?(result) do + assert_valid!(result) + + Proto.error?(result) + end @doc """ Returns true if the result is ok and it's value equals the test value. + + ## Examples + + iex> ok("Marty") + ...> |> contains?("Marty") + true + + iex> ok("Doc") + ...> |> contains?("Marty") + false + + iex> error("Marty") + ...> |> contains?("Marty") + false """ @spec contains?(t, any) :: boolean - def contains?(result, test_value), do: Proto.unwrap(result) == {:ok, test_value} + def contains?(result, test_value) do + assert_valid!(result) + + Proto.unwrap(result) == {:ok, test_value} + end @doc """ Returns true if the result is an error and it's value equals the test value. + + ## Examples + + iex> error("Marty") + ...> |> contains_error?("Marty") + true + + iex> ok("Doc") + ...> |> contains_error?("Marty") + false + + iex> ok("Marty") + ...> |> contains_error?("Marty") + false """ - @spec contains_err?(t, any) :: boolean - def contains_err?(result, test_value), do: Proto.unwrap(result) == {:error, test_value} + @spec contains_error?(t, any) :: boolean + def contains_error?(result, test_value) do + assert_valid!(result) + + Proto.unwrap(result) == {:error, test_value} + end @doc """ Maps an ok result into a new result using the provided function, leaving an error result untouched. + + ## Examples + + iex> ok(2) + ...> |> map(&({:ok, &1 * 2})) + {:ok, 4} + + iex> error(2) + ...> |> map(&({:ok, &1 * 2})) + {:error, 2} """ @spec map(t, map_fn) :: t def map(result, ok_callback) when is_function(ok_callback, 1) do - with {:ok, value} <- Proto.unwrap(result), - {:ok, value} <- safe_callback(value, ok_callback) do - {:ok, value} - else - _error -> result + assert_valid!(result) + + case Proto.unwrap(result) do + {:ok, value} -> + ok_callback + |> apply([value]) + |> assert_valid!() + + {:error, _} -> + result end end @doc """ Maps an ok result into a new result using the provided function, replacing an error value with the default. + + ## Examples + + iex> ok(2) + ...> |> map_or(&({:ok, &1 * 2}), 8) + {:ok, 4} + + iex> error(2) + ...> |> map_or(&({:ok, &1 * 2}), 8) + {:ok, 8} """ @spec map_or(t, map_fn, any) :: t def map_or(result, ok_callback, default) when is_function(ok_callback, 1) do - with {:ok, value} <- Proto.unwrap(result), - {:ok, value} <- safe_callback(value, ok_callback) do - {:ok, value} - else - _error -> {:ok, default} + assert_valid!(result) + + case Proto.unwrap(result) do + {:ok, value} -> + ok_callback + |> apply([value]) + |> assert_valid!() + + {:error, _} -> + {:ok, default} end end @doc """ Maps a result to another result by applying `ok_callback` or `err_callback` depending on whether the result is successful or not. + + ## Examples + + iex> ok(4) + ...> |> map_or_else(&({:ok, &1 * 2}), &({:ok, &1 * 3})) + {:ok, 8} + + iex> error(4) + ...> |> map_or_else(&({:ok, &1 * 2}), &({:ok, &1 * 3})) + {:ok, 12} """ @spec map_or_else(t, map_fn, map_fn) :: t def map_or_else(result, ok_callback, err_callback) do + assert_valid!(result) + case Proto.unwrap(result) do - {:ok, value} -> safe_callback(value, ok_callback) - {:error, value} -> safe_callback(value, err_callback) + {:ok, value} -> + ok_callback + |> apply([value]) + |> assert_valid!() + + {:error, value} -> + err_callback + |> apply([value]) + |> assert_valid!() end end @doc """ Maps an error result into another result by applying the function to an error value and leaving an ok value untouched. + + ## Examples + + iex> ok(2) + ...> |> map_err(&({:ok, &1 * 2})) + {:ok, 2} + + iex> error(3) + ...> |> map_err(&({:ok, &1 * 2})) + {:ok, 6} """ @spec map_err(t, map_fn) :: t def map_err(result, err_callback) do + assert_valid!(result) + case Proto.unwrap(result) do - {:error, value} -> safe_callback(value, err_callback) - _other -> result + {:error, value} -> + err_callback + |> apply([value]) + |> assert_valid!() + + {:ok, _} -> + result end end @@ -111,23 +252,57 @@ defmodule Upshot.Result do A logical and. If `lhs` is ok, returns `rhs`, otherwise returns `lhs`. + + ## Examples + + iex> and_(ok(1), ok(2)) + {:ok, 2} + + iex> and_(error(0), ok(2)) + {:error, 0} """ @spec and_(t, t) :: t def and_(lhs, rhs) do + assert_valid!(lhs) + assert_valid!(rhs) + case Proto.unwrap(lhs) do - {:ok, _value} -> rhs - _other -> lhs + {:ok, _} -> rhs + {:error, _} -> lhs end end @doc """ Calls `ok_callback` if the result is ok, otherwise returns the error value. + + ## Examples + + iex> square = &({:ok, &1 * &1}) + ...> ok(2) + ...> |> and_then(square) + ...> |> and_then(square) + ...> |> and_then(square) + {:ok, 256} + + iex> square = &({:ok, &1 * &1}) + ...> error(2) + ...> |> and_then(square) + ...> |> and_then(square) + ...> |> and_then(square) + {:error, 2} """ @spec and_then(t, map_fn) :: t def and_then(result, ok_callback) when is_function(ok_callback, 1) do + assert_valid!(result) + case Proto.unwrap(result) do - {:ok, value} -> safe_callback(value, ok_callback) - _other -> result + {:ok, value} -> + ok_callback + |> apply([value]) + |> assert_valid!() + + {:error, _} -> + result end end @@ -135,12 +310,23 @@ defmodule Upshot.Result do A logical or. If `lhs` is an error, returns `rhs`, otherwise returns `lhs`. + + ## Examples + + iex> or_(ok(1), ok(2)) + {:ok, 1} + + iex> or_(error(0), ok(2)) + {:ok, 2} """ @spec or_(t, t) :: t def or_(lhs, rhs) do + assert_valid!(lhs) + assert_valid!(rhs) + case Proto.unwrap(lhs) do {:error, _value} -> rhs - _ -> lhs + {:ok, _} -> lhs end end @@ -148,9 +334,21 @@ defmodule Upshot.Result do Unwraps the result and returns the value. Raises an `ExpectError` on failure with `message`. + + ## Examples + + iex> ok("Marty") + ...> |> expect!("the Delorean has no driver!") + "Marty" + + iex> error("Einstein") + ...> |> expect!("the Delorean has no driver!") + ** (Upshot.ExpectError) the Delorean has no driver! """ - @spec expect(t, String.t()) :: any | no_return - def expect(result, message) when is_binary(message) do + @spec expect!(t, String.t()) :: any | no_return + def expect!(result, message) when is_binary(message) do + assert_valid!(result) + case Proto.unwrap(result) do {:ok, value} -> value @@ -164,47 +362,79 @@ defmodule Upshot.Result do Unwraps the result and returns the value. Raises an `UnwrapError` on failure. + + ## Examples + + iex> ok("Marty") + ...> |> unwrap!() + "Marty" + + iex> error("Doc") + ...> |> unwrap!() + ** (Upshot.UnwrapError) Attempt to unwrap an error result. """ - @spec unwrap(t) :: any | no_return - def unwrap(result) do + @spec unwrap!(t) :: any | no_return + def unwrap!(result) do + assert_valid!(result) + case Proto.unwrap(result) do {:ok, value} -> value - {:error, _} -> raise UnwrapError, "Attempt to unwrap error result" + {:error, _} -> raise UnwrapError, "Attempt to unwrap an error result." end end @doc """ Returns the contained ok value, or a provided default. + + ## Examples + + iex> ok("Marty") + ...> |> unwrap_or("Doc") + "Marty" + + iex> error("Einsteim") + ...> |> unwrap_or("Doc") + "Doc" """ - @spec unwrap_or(t, any) :: any + @spec unwrap_or(t, any) :: any | no_return def unwrap_or(result, default) do case Proto.unwrap(result) do {:ok, value} -> value - _other -> default + {:error, _} -> default end end @doc """ Returns the contained ok value, or computes it with the provided function. + + ## Examples + + iex> ok("Marty") + ...> |> unwrap_or_else(fn _ -> {:ok, "Doc"} end) + "Marty" + + iex> error("Einstein") + ...> |> unwrap_or_else(fn _ -> {:ok, "Doc"} end) + "Doc" """ - @spec unwrap_or_else(t, map_fn) :: any + @spec unwrap_or_else(t, map_fn) :: any | no_return def unwrap_or_else(result, err_callback) when is_function(err_callback, 1) do + assert_valid!(result) + case Proto.unwrap(result) do {:ok, value} -> value {:error, value} -> - value - |> safe_callback(err_callback) - |> unwrap() + err_callback + |> apply([value]) + |> unwrap!() end end - # Calls a callback, but rescues exceptions and converts them results. - @spec safe_callback(any, map_fn) :: t - defp safe_callback(value, callback) do - apply(callback, [value]) - rescue - exception -> {:error, exception} + defp assert_valid!(result) do + if Proto.valid?(result), + do: result, + else: raise(ArgumentError, "Expected result, but got #{inspect(result)}") end end diff --git a/lib/upshot/result/proto.ex b/lib/upshot/result/proto.ex index b6d3b38..bad754d 100644 --- a/lib/upshot/result/proto.ex +++ b/lib/upshot/result/proto.ex @@ -1,18 +1,68 @@ defprotocol Upshot.Result.Proto do @moduledoc """ - The result protocol. - - Implement this for your own result types, as needed. + A protocol for interactint with optional types. """ - @fallback_to_any true + @doc """ + Used to check if the result contains a value. + ## Examples + + iex> ok?({:ok, "Marty"}) + true + + iex> ok?({:error, "Doc"}) + false + """ @spec ok?(t) :: boolean def ok?(_result) + @doc """ + Used to check if the result contains an error. + + ## Examples + + iex> error?({:ok, "Marty"}) + false + + iex> error?({:error, "Doc"}) + true + """ @spec error?(t) :: boolean def error?(_result) - @spec unwrap(t) :: {:ok, any} | {:error, any} + @doc """ + Converts the result into an ok or error tuple. + + This is used internally by the `Result` module for combinators, etc. + + ## Example + + iex> ResultExample.ok("Marty") + ...> |> unwrap() + {:ok, "Marty"} + + iex> ResultExample.error("Doc") + ...> |> unwrap() + {:error, "Doc"} + """ + @spec unwrap(t) :: {:ok, any} | {:error, any} | :invalid def unwrap(_result) + + @doc """ + Validates that the provided value is a valid result. + + Used internally by the `Result` module to ensure that arguments and callback + results are valid result. + + ## Examples + + iex> valid?({:ok, "Marty"}) + true + + iex valid?({"Marty", "Doc", "Einstein"}) + false + """ + @spec valid?(t) :: boolean + def valid?(_result) end diff --git a/lib/upshot/result/proto_any.ex b/lib/upshot/result/proto_any.ex deleted file mode 100644 index d24cb62..0000000 --- a/lib/upshot/result/proto_any.ex +++ /dev/null @@ -1,30 +0,0 @@ -defimpl Upshot.Result.Proto, for: Any do - @moduledoc """ - Implements the result protocol for arbitrary terms. - """ - - @doc """ - Returns `true` for `{:ok, value}`, otherwise `false`. - """ - @spec ok?(any) :: boolean - def ok?({:ok, _value}), do: true - def ok?(_result), do: false - - @doc """ - Returns `true` for `{:error, reason}` and `:error`. - """ - @spec error?(any) :: boolean - def error?({:error, _reason}), do: true - def error?(%{:__exception__ => true}), do: true - def error?(_result_), do: false - - @doc """ - Unwrap a result, if possible. - - Returns the value of an `{:ok, value}` tuple, otherwise `{:error, reason}`. - """ - @spec unwrap(any) :: {:ok, any} | {:error, any} - def unwrap({:ok, value}), do: {:ok, value} - def unwrap({:error, value}), do: {:error, value} - def unwrap(%{:__exception__ => true} = error), do: {:error, error} -end diff --git a/lib/upshot/result/proto_tuple.ex b/lib/upshot/result/proto_tuple.ex new file mode 100644 index 0000000..b62e597 --- /dev/null +++ b/lib/upshot/result/proto_tuple.ex @@ -0,0 +1,74 @@ +defimpl Upshot.Result.Proto, for: Tuple do + @moduledoc """ + Implements the option protocol for tuples, allowing `{:ok, any}` and `{:error, + any}` to be valid results. + """ + + @doc """ + Returns `true` for `{:ok, any}` tuples, `false` otherwise. + + ## Examples + + iex> ok?({:ok, "Marty"}) + true + + iex> ok?({:error, "Doc"}) + false + """ + @spec ok?(tuple) :: boolean + def ok?({:ok, _}), do: true + def ok?(_), do: false + + @doc """ + Returns `true` for `{:error, any}` tuples, `false` otherwise. + + ## Examples + + iex> error?({:ok, "Marty"}) + false + + iex> error?({:error, "Doc"}) + true + """ + @spec error?(tuple) :: boolean + def error?({:error, _}), do: true + def error?(_), do: false + + @doc """ + Passes ok and error tuples, converts other values to error tuples. + + ### Examples + + iex> unwrap({:ok, "Marty"}) + {:ok, "Marty"} + + iex> unwrap({:error, "Doc"}) + {:error, "Doc"} + + iex> unwrap({:einstein}) + {:error, :invalid_result} + """ + @spec unwrap(tuple) :: {:ok, any} | {:error, any} + def unwrap({:ok, _} = result), do: result + def unwrap({:error, _} = result), do: result + def unwrap(_), do: {:error, :invalid_result} + + @doc """ + Returns `true` for ok/error tuples, otherwise `false`. + + ## Examples + + iex> valid?({:ok, "Marty"}) + true + + iex> valid?({:error, "Doc"}) + true + + iex> valid?({}) + false + """ + @spec valid?(tuple) :: boolean + def valid?({:ok, _}), do: true + def valid?({:error, _}), do: true + def valid?(_), do: false +end diff --git a/test/support/option_example.ex b/test/support/option_example.ex index e2355fe..7ab2b91 100644 --- a/test/support/option_example.ex +++ b/test/support/option_example.ex @@ -1,6 +1,13 @@ defmodule OptionExample do defstruct optional_value: nil @moduledoc false + @behaviour Upshot.Option + + @impl true + def some(value), do: %OptionExample{optional_value: value} + + @impl true + def none, do: %OptionExample{optional_value: nil} defimpl Upshot.Option.Proto do def some?(%OptionExample{optional_value: nil}), do: false @@ -9,5 +16,7 @@ defmodule OptionExample do def none?(%OptionExample{}), do: false def unwrap(%OptionExample{optional_value: nil}), do: :error def unwrap(%OptionExample{optional_value: value}), do: {:ok, value} + + def valid?(%OptionExample{}), do: true end end diff --git a/test/support/result_example.ex b/test/support/result_example.ex index d356252..fd2fa6c 100644 --- a/test/support/result_example.ex +++ b/test/support/result_example.ex @@ -1,12 +1,23 @@ defmodule ResultExample do defstruct state: nil, value: nil + @behaviour Upshot.Result @moduledoc false + @impl true + def ok(value), do: %ResultExample{state: :ok, value: value} + + @impl true + def error(value), do: %ResultExample{state: :error, value: value} + defimpl Upshot.Result.Proto do def ok?(%ResultExample{state: :ok}), do: true def ok?(%ResultExample{}), do: false def error?(%ResultExample{state: :error}), do: true def error?(%ResultExample{}), do: false def unwrap(%ResultExample{state: state, value: value}), do: {state, value} + + def valid?(%ResultExample{state: :ok}), do: true + def valid?(%ResultExample{state: :error}), do: true + def valid?(_), do: false end end diff --git a/test/test_helper.exs b/test/test_helper.exs index fe144a1..c6e1954 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,3 @@ Mimic.copy(Upshot.Option.Proto) +Mimic.copy(Upshot.Result.Proto) ExUnit.start() diff --git a/test/upshot/option/proto_any_test.exs b/test/upshot/option/proto_any_test.exs deleted file mode 100644 index c41ff53..0000000 --- a/test/upshot/option/proto_any_test.exs +++ /dev/null @@ -1,35 +0,0 @@ -defmodule Upshot.Option.Proto.AnyTest do - use ExUnit.Case, async: true - alias Upshot.Option.Proto - @moduledoc false - - describe "some?/1" do - test "it is true for ok tuples" do - assert Proto.some?({:ok, "Marty"}) - end - - test "it is false for anything else" do - refute Proto.some?("Doc Brown") - end - end - - describe "none?/1" do - test "it is false for ok tuples" do - refute Proto.none?({:ok, "Marty"}) - end - - test "it is true for anything else" do - assert Proto.none?("Doc Brown") - end - end - - describe "unwrap/1" do - test "it passes through an ok tuple" do - assert {:ok, "Marty"} = Proto.unwrap({:ok, "Marty"}) - end - - test "it returns error for anything else" do - assert :error = Proto.unwrap("Doc Brown") - end - end -end diff --git a/test/upshot/option/proto_atom_test.exs b/test/upshot/option/proto_atom_test.exs new file mode 100644 index 0000000..79a17f8 --- /dev/null +++ b/test/upshot/option/proto_atom_test.exs @@ -0,0 +1,6 @@ +defmodule Upshot.Option.Proto.AtomTest do + use ExUnit.Case, async: true + import Upshot.Option.Proto + doctest Upshot.Option.Proto.Atom + @moduledoc false +end diff --git a/test/upshot/option/proto_test.exs b/test/upshot/option/proto_test.exs index 692a651..f7bc65b 100644 --- a/test/upshot/option/proto_test.exs +++ b/test/upshot/option/proto_test.exs @@ -1,34 +1,36 @@ defmodule Upshot.Option.ProtoTest do use ExUnit.Case, async: true - alias Upshot.Option.Proto + import Upshot.Option.Proto + doctest Upshot.Option.Proto @moduledoc false + describe "some?/1" do test "when the option has a value, it returns true" do - assert Proto.some?(%OptionExample{optional_value: "Marty"}) + assert some?(%OptionExample{optional_value: "Marty"}) end test "when the option has no value, it returns false" do - refute Proto.some?(%OptionExample{optional_value: nil}) + refute some?(%OptionExample{optional_value: nil}) end end describe "none?/1" do test "when the option has a value, it returns false" do - refute Proto.none?(%OptionExample{optional_value: "Marty"}) + refute none?(%OptionExample{optional_value: "Marty"}) end test "when the option has no value, it returns true" do - assert Proto.none?(%OptionExample{optional_value: nil}) + assert none?(%OptionExample{optional_value: nil}) end end describe "unwrap/1" do test "when the option has a value, it returns an ok tuple" do - assert {:ok, "Marty"} = Proto.unwrap(%OptionExample{optional_value: "Marty"}) + assert {:ok, "Marty"} = unwrap(%OptionExample{optional_value: "Marty"}) end test "when the option has no value, it returns an error" do - assert :error = Proto.unwrap(%OptionExample{optional_value: nil}) + assert :error = unwrap(%OptionExample{optional_value: nil}) end end end diff --git a/test/upshot/option/proto_tuple_test.exs b/test/upshot/option/proto_tuple_test.exs new file mode 100644 index 0000000..91d1bf9 --- /dev/null +++ b/test/upshot/option/proto_tuple_test.exs @@ -0,0 +1,6 @@ +defmodule Upshot.Option.Proto.TupleTest do + use ExUnit.Case, async: true + import Upshot.Option.Proto + doctest Upshot.Option.Proto.Tuple + @moduledoc false +end diff --git a/test/upshot/option_test.exs b/test/upshot/option_test.exs index fe13be6..b11cf69 100644 --- a/test/upshot/option_test.exs +++ b/test/upshot/option_test.exs @@ -1,142 +1,144 @@ defmodule Upshot.OptionTest do use ExUnit.Case, async: true use Mimic - alias Upshot.{Option, Option.Proto} + alias Upshot.Option.Proto + import Upshot.Option + doctest Upshot.Option @moduledoc false describe "some/1" do test "it returns an ok tuple" do - assert {:ok, "Marty"} = Option.some("Marty") + assert {:ok, "Marty"} = some("Marty") end end describe "none/1" do test "it returns an error atom" do - assert :error = Option.none() + assert :error = none() end end describe "some?/1" do test "it delegates to Proto" do Proto - |> expect(:some?, &assert(&1 == "Marty")) + |> expect(:some?, &assert(&1 == {:ok, "Marty"})) - Option.some?("Marty") + some?({:ok, "Marty"}) end end describe "none?/1" do test "it delegates to Proto" do Proto - |> expect(:none?, &assert(&1 == "Doc Brown")) + |> expect(:none?, &assert(&1 == :error)) - Option.none?("Doc Brown") + none?(:error) end end describe "ok_or/2" do test "when the option contains a value it returns it" do - assert {:ok, "Marty"} = Option.ok_or({:ok, "Marty"}, "Doc Brown") + assert {:ok, "Marty"} = ok_or({:ok, "Marty"}, "Doc Brown") end test "when the option does not contain a value it returns the error value" do - assert {:error, "Doc Brown"} = Option.ok_or(Option.none(), "Doc Brown") + assert {:error, "Doc Brown"} = ok_or(none(), "Doc Brown") end end describe "ok_or_else/2" do test "when the option contains a value it returns it" do - assert {:ok, "Marty"} = Option.ok_or({:ok, "Marty"}, "Doc Brown") + assert {:ok, "Marty"} = ok_or({:ok, "Marty"}, "Doc Brown") end test "when the option does not contain a value it converts the result of the callback into an error" do - assert {:error, "Doc Brown"} = Option.ok_or_else(Option.none(), fn -> "Doc Brown" end) + assert {:error, "Doc Brown"} = ok_or_else(none(), fn -> "Doc Brown" end) end end describe "and_/2" do test "when the lhs option is none, it returns it" do - assert Option.none() == Option.and_(Option.none(), Option.some("Marty")) + assert none() == and_(none(), some("Marty")) end test "when the lhs option is some, it returns the rhs option" do - assert {:ok, "Doc Brown"} = Option.and_({:ok, "Marty"}, {:ok, "Doc Brown"}) + assert {:ok, "Doc Brown"} = and_({:ok, "Marty"}, {:ok, "Doc Brown"}) end end describe "and_then/2" do test "when the option is none, it returns it" do - assert Option.none() == Option.and_then(Option.none(), &Option.some(&1 * 2)) + assert none() == and_then(none(), &some(&1 * 2)) end test "when the option is some, it maps the value into a new option" do - assert Option.some(4) == Option.and_then(Option.some(2), &Option.some(&1 * 2)) + assert some(4) == and_then(some(2), &some(&1 * 2)) end end describe "filter/2" do test "when the option is none, it returns none" do - assert Option.none() == Option.filter(Option.none(), fn _ -> false end) + assert none() == filter(none(), fn _ -> false end) end test "when the option is some and the predicate is false, it returns none" do - assert Option.none() == Option.filter(Option.some("Marty"), fn _ -> false end) + assert none() == filter(some("Marty"), fn _ -> false end) end test "when the option is some and the predicate is true, it returns it" do - assert Option.some("Marty") == Option.filter(Option.some("Marty"), fn _ -> true end) + assert some("Marty") == filter(some("Marty"), fn _ -> true end) end end describe "or_/2" do test "when the lhs option is some, it returns it" do - assert {:ok, "Marty"} = Option.or_(Option.some("Marty"), Option.some("Doc")) + assert {:ok, "Marty"} = or_(some("Marty"), some("Doc")) end test "when the lhs option is none, it returns the rhs option" do - assert {:ok, "Doc"} = Option.or_(Option.none(), Option.some("Doc")) + assert {:ok, "Doc"} = or_(none(), some("Doc")) end end describe "or_else/2" do test "when the option is some, it returns it" do - assert {:ok, "Marty"} = Option.or_else(Option.some("Marty"), fn -> Option.some("Doc") end) + assert {:ok, "Marty"} = or_else(some("Marty"), fn -> some("Doc") end) end test "when the option is none, calls the callback and returns it's option" do - assert {:ok, "Doc"} = Option.or_else(Option.none(), fn -> Option.some("Doc") end) + assert {:ok, "Doc"} = or_else(none(), fn -> some("Doc") end) end end describe "xor/2" do test "when the lhs option is some and the rhs option is none, it returns the lhs option" do - assert {:ok, "Marty"} = Option.xor(Option.some("Marty"), Option.none()) + assert {:ok, "Marty"} = xor(some("Marty"), none()) end test "when the lhs option is none and the rhs is some, it returns the rhs option" do - assert {:ok, "Doc"} = Option.xor(Option.none(), Option.some("Doc")) + assert {:ok, "Doc"} = xor(none(), some("Doc")) end test "when both options are some, it returns none" do - assert Option.none() == Option.xor(Option.some("Marty"), Option.some("Doc")) + assert none() == xor(some("Marty"), some("Doc")) end test "when both options are none, it returns none" do - assert Option.none() == Option.xor(Option.none(), Option.none()) + assert none() == xor(none(), none()) end end describe "zip/2" do test "when both options are some, it zips them together" do - assert Option.some({"Marty", "Doc"}) == Option.zip(Option.some("Marty"), Option.some("Doc")) + assert some({"Marty", "Doc"}) == zip(some("Marty"), some("Doc")) end test "when the lhs option is none, it returns none" do - assert Option.none() == Option.zip(Option.none(), Option.some("Doc")) + assert none() == zip(none(), some("Doc")) end test "when the rhs option is none, it returns none" do - assert Option.none() == Option.zip(Option.some("Marty"), Option.none()) + assert none() == zip(some("Marty"), none()) end end end diff --git a/test/upshot/result/proto_any_test.exs b/test/upshot/result/proto_any_test.exs deleted file mode 100644 index 51ed210..0000000 --- a/test/upshot/result/proto_any_test.exs +++ /dev/null @@ -1,43 +0,0 @@ -defmodule Upshot.Result.Proto.AnyTest do - use ExUnit.Case, async: true - alias Upshot.{Result.Proto, UnwrapError} - - describe "ok?/1" do - test "when the result is an ok tuple, it returns true" do - assert Proto.ok?({:ok, "Marty"}) - end - - test "when the result is anything else, it returns false" do - refute Proto.ok?("Doc Brown") - end - end - - describe "error?/1" do - test "when the result is an error tuple, it returns true" do - assert Proto.error?({:error, "Marty"}) - end - - test "when the result is an exception, it returns true" do - assert Proto.error?(UnwrapError.exception("Marty")) - end - - test "when the result is anything else, it returns false" do - refute Proto.error?("Doc Brown") - end - end - - describe "unwrap/1" do - test "it passes through an ok tuple untouched" do - assert {:ok, "Marty"} = Proto.unwrap({:ok, "Marty"}) - end - - test "it passes through an error tuple untouched" do - assert {:error, "Marty"} = Proto.unwrap({:error, "Marty"}) - end - - test "it converts an exception into an error tuple" do - exception = UnwrapError.exception("Marty") - assert {:error, ^exception} = Proto.unwrap(exception) - end - end -end diff --git a/test/upshot/result/proto_test.exs b/test/upshot/result/proto_test.exs index cae515a..530b842 100644 --- a/test/upshot/result/proto_test.exs +++ b/test/upshot/result/proto_test.exs @@ -1,35 +1,50 @@ defmodule Upshot.Result.ProtoTest do use ExUnit.Case, async: true - alias Upshot.Result.Proto + import Upshot.Result.Proto + doctest Upshot.Result.Proto @moduledoc false describe "ok?/1" do test "when the result is ok, it returns true" do - assert Proto.ok?(%ResultExample{state: :ok}) + assert ok?(%ResultExample{state: :ok}) end test "when the result is not ok, it returns false" do - refute Proto.ok?(%ResultExample{state: :error}) + refute ok?(%ResultExample{state: :error}) end end describe "error?/1" do test "when the result is error, it returns true" do - assert Proto.error?(%ResultExample{state: :error}) + assert error?(%ResultExample{state: :error}) end test "when the result is not error, it returns false" do - refute Proto.error?(%ResultExample{state: :ok}) + refute error?(%ResultExample{state: :ok}) end end describe "unwrap/1" do test "when the result is ok, it returns an ok tuple" do - assert {:ok, "Marty"} = Proto.unwrap(%ResultExample{state: :ok, value: "Marty"}) + assert {:ok, "Marty"} = unwrap(ResultExample.ok("Marty")) end test "when the result is error, it returns an error tuple" do - assert {:error, "Marty"} = Proto.unwrap(%ResultExample{state: :error, value: "Marty"}) + assert {:error, "Doc"} = unwrap(ResultExample.error("Doc")) + end + end + + describe "valid?/1" do + test "when the result is ok, it returns true" do + assert valid?(ResultExample.ok("Marty")) + end + + test "when the result is error, it returns true" do + assert valid?(ResultExample.error("Doc")) + end + + test "when the result is invalid, it returns false" do + refute valid?(%ResultExample{}) end end end diff --git a/test/upshot/result/proto_tuple_test.exs b/test/upshot/result/proto_tuple_test.exs new file mode 100644 index 0000000..5a8077f --- /dev/null +++ b/test/upshot/result/proto_tuple_test.exs @@ -0,0 +1,5 @@ +defmodule Upshot.Result.Proto.TupleTest do + use ExUnit.Case, async: true + import Upshot.Result.Proto + doctest Upshot.Result.Proto.Tuple +end diff --git a/test/upshot/result_test.exs b/test/upshot/result_test.exs new file mode 100644 index 0000000..512fee8 --- /dev/null +++ b/test/upshot/result_test.exs @@ -0,0 +1,26 @@ +defmodule Upshot.ResultTest do + use ExUnit.Case, async: true + use Mimic + alias Upshot.Result.Proto + import Upshot.Result + doctest Upshot.Result + @moduledoc false + + describe "ok?/1" do + test "it delegates to Proto" do + Proto + |> expect(:ok?, &assert(&1 == {:ok, "Marty"})) + + ok?({:ok, "Marty"}) + end + end + + describe "error?/1" do + test "it delegates to Proto" do + Proto + |> expect(:error?, &assert(&1 == {:ok, "Marty"})) + + error?({:ok, "Marty"}) + end + end +end