Skip to content

Commit

Permalink
[Change] improve errors, types and docs
Browse files Browse the repository at this point in the history
  • Loading branch information
albertored committed Jun 26, 2020
1 parent 9d254ca commit efdddba
Show file tree
Hide file tree
Showing 17 changed files with 179 additions and 119 deletions.
27 changes: 15 additions & 12 deletions lib/ex_sieve.ex
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
defmodule ExSieve do
@moduledoc """
ExSieve is a object query translator to Ecto.Query.
"""

alias ExSieve.Config

@doc """
ExSieve is meant to be `use`d by a Ecto.Repo.
`ExSieve` is meant to be `use`d by a module implementing `Ecto.Repo` behaviour.
When `use`d, an optional default for `ignore_erros` can be provided.
If `ignore_erros` is not provided, a default of `true` will be used.
When used, optional configuration parameters can be provided.
For details about cofngiuration parameters see `t:ExSieve.Config.t/0`.
defmodule MyApp.Repo do
use Ecto.Repo, otp_app: :my_app
Expand All @@ -21,7 +15,7 @@ defmodule ExSieve do
use ExSieve, ignore_erros: true
end
When `use` is called, a `filter` function is defined in the Repo.
When `use` is called, a `filter` function is defined in the Repo.
"""

defmacro __using__(opts) do
Expand All @@ -36,8 +30,17 @@ defmodule ExSieve do
end
end

@typep error :: :invalid_query | :attribute_not_found | :predicate_not_found | :direction_not_found | :value_is_empty
@type result :: Ecto.Query.t() | {:error, error}
@type result :: Ecto.Query.t() | error()

@type error ::
{:error, :invalid_query}
| {:error, {:too_deep, key :: String.t()}}
| {:error, {:predicate_not_found, key :: String.t()}}
| {:error, {:attribute_not_found, key :: String.t()}}
| {:error, {:direction_not_found, invalid_direction :: String.t()}}
| {:error, {:value_is_empty, key :: String.t()}}
| {:error, {:invalid_type, field :: String.t()}}
| {:error, {:invalid_value, {field :: String.t(), value :: any()}}}

@doc """
Filters the given query based on params.
Expand Down
6 changes: 5 additions & 1 deletion lib/ex_sieve/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ defmodule ExSieve.Builder do
alias ExSieve.Builder.{Join, OrderBy, Where}
alias ExSieve.Node.{Grouping, Sort}

@spec call(Ecto.Queryable.t(), Grouping.t(), list(Sort.t()), Config.t()) :: {:ok, Ecto.Query.t()} | {:error, any()}
@spec call(Ecto.Queryable.t(), Grouping.t(), list(Sort.t()), Config.t()) ::
{:ok, Ecto.Query.t()}
| {:error, {:predicate_not_found, predicate :: atom()}}
| {:error, {:invalid_type, field :: String.t()}}
| {:error, {:invalid_value, {field :: String.t(), value :: any()}}}
def call(query, grouping, sorts, config) do
with {:ok, query} <- Join.build(query, grouping, sorts),
{:ok, query} <- Where.build(query, grouping, config),
Expand Down
15 changes: 10 additions & 5 deletions lib/ex_sieve/builder/where.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,12 @@ defmodule ExSieve.Builder.Where do
@spec composite_predicates :: [String.t()]
def composite_predicates, do: @all_any_predicates_str

@spec build(Ecto.Queryable.t(), Grouping.t(), Config.t()) :: {:ok, Ecto.Query.t()} | {:error, any()}
@spec build(Ecto.Queryable.t(), Grouping.t(), Config.t()) ::
{:ok, Ecto.Query.t()}
| {:error, {:predicate_not_found, predicate :: atom()}}
| {:error, {:invalid_type, field :: String.t()}}
| {:error, {:invalid_value, {field :: String.t(), value :: any()}}}

def build(query, %Grouping{combinator: combinator} = grouping, config) when combinator in ~w(and or)a do
case dynamic_grouping(grouping, config) do
{:error, _} = err -> err
Expand Down Expand Up @@ -124,20 +129,20 @@ defmodule ExSieve.Builder.Where do
unless allowed_types == :all do
defp validate_dynamic(unquote(predicate), %Attribute{type: type} = attr, _)
when type not in unquote(allowed_types) do
{:error, {:invalid_type, attr}}
{:error, {:invalid_type, Utils.rebuild_key(attr)}}
end
end

unless allowed_values == :all do
defp validate_dynamic(unquote(predicate), attr, [value | _]) when value not in unquote(allowed_values) do
{:error, {:invalid_value, attr}}
{:error, {:invalid_value, {Utils.rebuild_key(attr), value}}}
end
end
end

defp validate_dynamic(predicate, _attribute, _values) when predicate in @predicates, do: :ok

defp validate_dynamic(_predicate, _attribute, _values), do: {:error, :predicate_not_found}
defp validate_dynamic(predicate, _attribute, _values), do: {:error, {:predicate_not_found, predicate}}

defp build_dynamic(:eq, %Attribute{parent: [], name: name}, [value | _]) do
dynamic([p], field(p, ^name) == ^value)
Expand Down Expand Up @@ -307,7 +312,7 @@ defmodule ExSieve.Builder.Where do
dynamic([{^parent_name(parent), p}], not (is_nil(field(p, ^name)) or field(p, ^name) == ^""))
end

defp build_dynamic(_predicate, _attribute, _values), do: {:error, :predicate_not_found}
defp build_dynamic(predicate, _attribute, _values), do: {:error, {:predicate_not_found, predicate}}

defp escape_like_value(value), do: Regex.replace(~r/([\%_])/, value, ~S(\\\1))
end
26 changes: 20 additions & 6 deletions lib/ex_sieve/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,24 @@ defmodule ExSieve.Config do

@typedoc """
`ExSieve` configuration options:
* `ignore_errors`
* `max_depth`
* `except_predicates`
* `only_predicates`
* `:ignore_errors` - when `true` recoverable errors are ignored. Recoverable
errors include for instance missing attribute or missing preedicate, in that
case the query is returned without taking into account the filter causing the
error. Defaults to `true`
* `:max_depth` - the maximum level of nested relations that can be queried.
Defaults to `:full` meaning no limit
* `:only_predicates` - a list of allowed predicates. The list can contain `:basic`
and `:composite`, in that case all correpsonding predicates are added to the list.
When not given or when `nil` no limit is applied. Defaults to `nil`
* `:except_predicates` - a list of excluded predicates. The list can contain `:basic`
and `:composite`, in that case all correpsonding predicates are added to the list.
When not given or when `nil` no limit is applied. If both `:only_predicates` and
`:except_predicates` are given `:only_predicates` takes precedence and
`:except_predicates` is ignored. Defaults to `nil`
"""
@type t :: %__MODULE__{
ignore_errors: boolean(),
Expand All @@ -18,7 +32,7 @@ defmodule ExSieve.Config do
@keys [:ignore_errors, :max_depth, :except_predicates, :only_predicates]

@doc false
@spec new(Keyword.t(), call_options :: map, schema :: module()) :: ExSieve.Config.t()
@spec new(Keyword.t(), call_options :: map, schema :: module()) :: __MODULE__.t()
def new(defaults, call_options, schema) do
defaults = normalize_options(defaults)
call_options = normalize_options(call_options)
Expand All @@ -30,7 +44,7 @@ defmodule ExSieve.Config do
|> Map.merge(call_options)
|> Map.take(@keys)

struct(ExSieve.Config, opts)
struct(__MODULE__, opts)
end

defp options_from_schema(schema) do
Expand Down
26 changes: 6 additions & 20 deletions lib/ex_sieve/filter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,13 @@ defmodule ExSieve.Filter do
alias ExSieve.{Builder, Config, Node, Utils}

@spec filter(Ecto.Queryable.t(), %{(binary | atom) => term}, defaults :: Keyword.t(), options :: map) ::
ExSieve.result() | {:error, any()}
ExSieve.result()
def filter(queryable, params, defaults \\ [], options \\ %{}) do
case Utils.extract_schema(queryable) do
{:ok, schema} ->
config = Config.new(defaults, options, schema)

params
|> Node.call(schema, config)
|> result(queryable, config)

{:error, _} = err ->
err
end
end

defp result({:error, reason}, _queryable, _config), do: {:error, reason}

defp result({:ok, groupings, sorts}, queryable, config) do
case Builder.call(queryable, groupings, sorts, config) do
{:ok, result} -> result
err -> err
with {:ok, schema} <- Utils.extract_schema(queryable),
config <- Config.new(defaults, options, schema),
{:ok, groupings, sorts} <- Node.call(params, schema, config),
{:ok, result} <- Builder.call(queryable, groupings, sorts, config) do
result
end
end
end
18 changes: 11 additions & 7 deletions lib/ex_sieve/node.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,29 @@ defmodule ExSieve.Node do
alias ExSieve.Node.{Grouping, Sort}
alias ExSieve.{Config, Utils}

@typep error :: {:error, :attribute_not_found | :predicate_not_found | :direction_not_found}
@type error ::
{:error, {:too_deep, key :: String.t()}}
| {:error, {:predicate_not_found, key :: String.t()}}
| {:error, {:attribute_not_found, key :: String.t()}}
| {:error, {:direction_not_found, invalid_direction :: String.t()}}
| {:error, {:value_is_empty, key :: String.t()}}

@spec call(%{(atom | binary) => term}, atom, Config.t()) :: {:ok, Grouping.t(), list(Sort.t())} | error
def call(params_with_sort, schema, config) do
params_with_sort = stringify_keys(params_with_sort)
{params, sorts} = extract_sorts(params_with_sort, schema, config)
grouping = Grouping.extract(params, schema, config)
result(grouping, Utils.get_error(sorts, config))

with sorts when is_list(sorts) <- Utils.get_error(sorts, config),
%Grouping{} = grouping <- Grouping.extract(params, schema, config) do
{:ok, grouping, sorts}
end
end

defp extract_sorts(params, schema, config) do
{sorts, params} = Map.pop(params, "s", [])
{params, Sort.extract(sorts, schema, config)}
end

defp result({:error, reason}, _sorts), do: {:error, reason}
defp result(_grouping, {:error, reason}), do: {:error, reason}
defp result(grouping, sorts), do: {:ok, grouping, sorts}

defp stringify_keys(nil), do: nil
defp stringify_keys(%{__struct__: _struct} = value), do: value
defp stringify_keys(%{} = map), do: Map.new(map, fn {k, v} -> {to_string(k), stringify_keys(v)} end)
Expand Down
9 changes: 6 additions & 3 deletions lib/ex_sieve/node/attribute.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ defmodule ExSieve.Node.Attribute do
@type t :: %__MODULE__{}

@spec extract(key :: String.t(), module | %{related: module}, Config.t()) ::
t() | {:error, :attribute_not_found | :too_deep}
t()
| {:error, {:attribute_not_found, key :: String.t()}}
| {:error, {:too_deep, key :: String.t()}}

def extract(key, module, config) do
extract(key, module, {:name, get_name_and_type(module, key)}, [], config)
end
Expand All @@ -18,15 +21,15 @@ defmodule ExSieve.Node.Attribute do
if md == :full or (is_integer(md) and length(parents) < md) do
extract(key, module, {:assoc, get_assoc(module, key)}, parents, config)
else
{:error, :too_deep}
{:error, {:too_deep, Utils.rebuild_key(key, parents)}}
end
end

defp extract(_, _, {:name, {name, type}}, parents, _config) do
%Attribute{parent: Enum.reverse(parents), name: name, type: type}
end

defp extract(_, _, {:assoc, nil}, _, _), do: {:error, :attribute_not_found}
defp extract(key, _, {:assoc, nil}, parents, _), do: {:error, {:attribute_not_found, Utils.rebuild_key(key, parents)}}

defp extract(key, module, {:assoc, assoc}, parents, config) do
key = String.replace_prefix(key, "#{assoc}_", "")
Expand Down
19 changes: 11 additions & 8 deletions lib/ex_sieve/node/condition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@ defmodule ExSieve.Node.Condition do
@typep values :: String.t() | integer | list(String.t() | integer)

@spec extract(String.t() | atom, values, module(), Config.t()) ::
t | {:error, :predicate_not_found | :value_is_empty | :attribute_not_found}
t()
| {:error, {:predicate_not_found, key :: String.t()}}
| {:error, {:attribute_not_found, key :: String.t()}}
| {:error, {:value_is_empty, key :: String.t()}}
def extract(key, values, module, config) do
with {:ok, attributes} <- extract_attributes(key, module, config),
{:ok, predicate} <- get_predicate(key, config),
{:ok, values} <- prepare_values(values) do
{:ok, values} <- prepare_values(values, key) do
%Condition{
attributes: attributes,
predicate: predicate,
Expand Down Expand Up @@ -62,7 +65,7 @@ defmodule ExSieve.Node.Condition do
|> Enum.sort_by(&byte_size/1, &>=/2)
|> Enum.find(&String.ends_with?(key, &1))
|> case do
nil -> {:error, :predicate_not_found}
nil -> {:error, {:predicate_not_found, key}}
predicate -> {:ok, String.to_atom(predicate)}
end
end
Expand All @@ -75,18 +78,18 @@ defmodule ExSieve.Node.Condition do
end
end

defp prepare_values(values) when is_list(values) do
defp prepare_values(values, key) when is_list(values) do
values
|> Enum.all?(&match?({:ok, _val}, prepare_values(&1)))
|> Enum.all?(&match?({:ok, _val}, prepare_values(&1, key)))
|> if do
{:ok, values}
else
{:error, :value_is_empty}
{:error, {:value_is_empty, key}}
end
end

defp prepare_values(""), do: {:error, :value_is_empty}
defp prepare_values(value), do: {:ok, List.wrap(value)}
defp prepare_values("", key), do: {:error, {:value_is_empty, key}}
defp prepare_values(value, _key), do: {:ok, List.wrap(value)}

defp replace_groups(nil, except), do: {nil, do_replace_groups(except)}
defp replace_groups(only, _), do: {do_replace_groups(only), nil}
Expand Down
17 changes: 9 additions & 8 deletions lib/ex_sieve/node/grouping.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,25 @@ defmodule ExSieve.Node.Grouping do

@combinators ~w(or and)

@spec extract(%{binary => term}, atom, Config.t()) :: t | {:error, :predicate_not_found | :value_is_empty}
@spec extract(%{binary => term}, atom, Config.t()) ::
t()
| {:error, {:attribute_not_found, key :: String.t()}}
| {:error, {:predicate_not_found, key :: String.t()}}
| {:error, {:value_is_empty, key :: String.t()}}
def extract(params, schema, config) do
{combinator, params} = Map.pop(params, "m", "and")
{grouping, params} = Map.pop(params, "g", [])
conditions = Map.get(params, "c", params)

conditions
|> do_extract(schema, config, valid_combinator(combinator))
|> result(extract_groupings(grouping, schema, config))
with %Grouping{} = extracted <- do_extract(conditions, schema, config, valid_combinator(combinator)),
groupings when is_list(groupings) <- extract_groupings(grouping, schema, config) do
%Grouping{extracted | groupings: groupings}
end
end

defp valid_combinator(combinator) when combinator in @combinators, do: String.to_atom(combinator)
defp valid_combinator(_combinator), do: :and

defp result({:error, _} = err, _groupings), do: err
defp result(_grouping, {:error, _} = err), do: err
defp result(grouping, groupings), do: %Grouping{grouping | groupings: groupings}

defp do_extract(conditions, schema, config, combinator) do
case extract_conditions(conditions, schema, config) do
{:error, _} = err -> err
Expand Down
16 changes: 12 additions & 4 deletions lib/ex_sieve/node/sort.ex
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ defmodule ExSieve.Node.Sort do
@directions ~w(desc asc)

@spec extract(String.t() | list(String.t()), module(), Config.t()) ::
list(t | {:error, :attribute_not_found | :direction_not_found})
list(
t()
| {:error, {:attribute_not_found, key :: String.t()}}
| {:error, {:direction_not_found, invalid_direction :: String.t()}}
)
def extract(value, schema, %Config{} = config) when is_bitstring(value) do
value
|> build(schema, config)
Expand All @@ -26,11 +30,15 @@ defmodule ExSieve.Node.Sort do
|> result(parse_direction(value))
end

defp result(_attribute, nil), do: {:error, :direction_not_found}
defp result(_attribute, {:error, invalid_dir}), do: {:error, {:direction_not_found, invalid_dir}}
defp result({:error, reason}, _direction), do: {:error, reason}
defp result(attribute, direction), do: %Sort{attribute: attribute, direction: String.to_atom(direction)}
defp result(attribute, {:ok, direction}), do: %Sort{attribute: attribute, direction: String.to_atom(direction)}

defp parse_direction(value) do
value |> String.split(~r/\s+/) |> Enum.find(&Enum.member?(@directions, &1))
case String.split(value, ~r/\s+/) do
[_, direction] when direction in @directions -> {:ok, direction}
[_, invalid_direction] -> {:error, invalid_direction}
_ -> {:error, value}
end
end
end
4 changes: 2 additions & 2 deletions lib/ex_sieve/schema.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule ExSieve.Schema do
@moduledoc """
`ExSieve.Schema` is meant to be `use`d by a module using `Ecto.Schema`.
`ExSieve.Schema` is meant to be `use`d by modules using `Ecto.Schema`.
When used, optional configuration parameters specific for the schema
can be provided. For details about cofngiuration parameters see
Expand Down Expand Up @@ -35,7 +35,7 @@ defmodule ExSieve.Schema do
end
Filters for fields that are in the list are ignored (an error is returned
if `ignore_errors` is `false`). By default all fields are filterable.
if `:ignore_errors` is `false`). By default all fields are filterable.
"""

defmacro __using__(opts) do
Expand Down
Loading

0 comments on commit efdddba

Please sign in to comment.