Skip to content

Commit

Permalink
Merge pull request #21 from valyukov/extra-config
Browse files Browse the repository at this point in the history
Provide extra configuration
  • Loading branch information
albertored committed Jun 29, 2020
2 parents 42bdc70 + 18d79bc commit 439f8b1
Show file tree
Hide file tree
Showing 24 changed files with 517 additions and 186 deletions.
29 changes: 16 additions & 13 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 @@ -31,13 +25,22 @@ defmodule ExSieve do
@ex_sieve_defaults unquote(opts)

def filter(queryable, params, options \\ %{}) do
ExSieve.Filter.filter(queryable, params, Config.new(@ex_sieve_defaults, options))
ExSieve.Filter.filter(queryable, params, @ex_sieve_defaults, options)
end
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
25 changes: 19 additions & 6 deletions lib/ex_sieve/builder/where.ex
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,31 @@ defmodule ExSieve.Builder.Where do
]

@basic_predicates Enum.map(@predicates_opts, &elem(&1, 0))
@basic_predicates_str Enum.map(@basic_predicates, &Atom.to_string/1)

@all_any_predicates Enum.flat_map(@predicates_opts, fn {predicate, _, _, all_any} ->
Enum.map(all_any, &:"#{predicate}_#{&1}")
end)
@all_any_predicates_str Enum.map(@all_any_predicates, &Atom.to_string/1)

@predicates @basic_predicates ++ @all_any_predicates
@predicates_str Enum.map(@predicates, &Atom.to_string/1)
@predicates_str @basic_predicates_str ++ @all_any_predicates_str

@spec predicates() :: [String.t()]
def predicates, do: @predicates_str

@spec build(Ecto.Queryable.t(), Grouping.t(), Config.t()) :: {:ok, Ecto.Query.t()} | {:error, any()}
@spec basic_predicates :: [String.t()]
def basic_predicates, do: @basic_predicates_str

@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, {: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 @@ -116,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 @@ -299,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
72 changes: 52 additions & 20 deletions lib/ex_sieve/config.ex
Original file line number Diff line number Diff line change
@@ -1,31 +1,63 @@
defmodule ExSieve.Config do
@moduledoc """
A `ExSieve.Config` can be created with a `ignore_errors` true or false
```
%ExSieve.Config{
ignore_errors: true
}
```
defstruct ignore_errors: true, max_depth: :full, except_predicates: nil, only_predicates: nil

@typedoc """
`ExSieve` configuration options:
* `:ignore_errors` - when `true` recoverable errors are ignored. Recoverable
errors include for instance missing attribute or missing predicate, 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 corresponding 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 corresponding 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`
"""
defstruct ignore_errors: true
@type t :: %__MODULE__{
ignore_errors: boolean(),
max_depth: non_neg_integer() | :full,
except_predicates: [String.t() | :basic | :composite] | nil,
only_predicates: [String.t() | :basic | :composite] | nil
}

@type t :: %__MODULE__{}
@keys [:ignore_errors, :max_depth, :except_predicates, :only_predicates]

@doc false
@spec new(Keyword.t(), map) :: ExSieve.Config.t()
def new(defaults, options \\ %{}) do
%ExSieve.Config{ignore_errors: ignore_errors?(defaults, options)}
@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)
schema_options = schema |> options_from_schema() |> normalize_options()

opts =
defaults
|> Map.merge(schema_options)
|> Map.merge(call_options)
|> Map.take(@keys)

struct(__MODULE__, opts)
end

defp normalize_options(options) do
Enum.reduce(options, %{}, fn {k, v}, map ->
Map.put(map, to_string(k), v)
end)
defp options_from_schema(schema) do
cond do
function_exported?(schema, :__ex_sieve_options__, 0) -> apply(schema, :__ex_sieve_options__, [])
true -> %{}
end
end

defp ignore_errors?(defaults, options) do
options
|> normalize_options
|> Map.get("ignore_errors", Keyword.get(defaults, :ignore_errors, true))
defp normalize_options(options) when is_list(options) or is_map(options) do
Map.new(options, fn
{key, val} when is_atom(key) -> {key, val}
{key, val} when is_bitstring(key) -> {String.to_existing_atom(key), val}
end)
end
end
38 changes: 9 additions & 29 deletions lib/ex_sieve/filter.ex
Original file line number Diff line number Diff line change
@@ -1,36 +1,16 @@
defmodule ExSieve.Filter do
@moduledoc false

alias ExSieve.{Builder, Config, Node}
alias ExSieve.{Builder, Config, Node, Utils}

@spec filter(Ecto.Queryable.t(), %{(binary | atom) => term}, Config.t()) :: ExSieve.result() | {:error, any()}
def filter(queryable, params, %Config{} = config) do
case extract_schema(queryable) do
{:ok, schema} ->
params
|> Node.call(schema, config)
|> result(queryable, config)

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
end
end

defp extract_schema(%Ecto.Query{from: %{source: {_, module}}}), do: extract_schema(module)

defp extract_schema(schema) when is_atom(schema) do
cond do
function_exported?(schema, :__schema__, 1) -> {:ok, schema}
true -> {:error, :invalid_query}
@spec filter(Ecto.Queryable.t(), %{(binary | atom) => term}, defaults :: Keyword.t(), options :: map) ::
ExSieve.result()
def filter(queryable, params, defaults \\ [], options \\ %{}) do
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
24 changes: 14 additions & 10 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)
grouping = Grouping.extract(params, schema, config)
result(grouping, Utils.get_error(sorts, config))
{params, sorts} = extract_sorts(params_with_sort, schema, 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) do
defp extract_sorts(params, schema, config) do
{sorts, params} = Map.pop(params, "s", [])
{params, Sort.extract(sorts, schema)}
{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
38 changes: 29 additions & 9 deletions lib/ex_sieve/node/attribute.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,38 @@ defmodule ExSieve.Node.Attribute do

defstruct name: nil, parent: nil, type: nil

alias ExSieve.{Config, Utils}
alias ExSieve.Node.Attribute

@type t :: %__MODULE__{}

@spec extract(key :: String.t(), module | %{related: module}) :: t() | {:error, :attribute_not_found}
def extract(key, module) do
extract(key, module, {:name, get_name_and_type(module, key)}, [])
@spec extract(key :: String.t(), module | %{related: module}, Config.t()) ::
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

defp extract(key, module, {:name, nil}, parents) do
extract(key, module, {:assoc, get_assoc(module, key)}, parents)
defp extract(key, module, {:name, nil}, parents, %Config{max_depth: md} = config) 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, Utils.rebuild_key(key, parents)}}
end
end

defp extract(_, _, {:name, {name, type}}, parents) do
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) do
defp extract(key, module, {:assoc, assoc}, parents, config) do
key = String.replace_prefix(key, "#{assoc}_", "")
module = get_assoc_module(module, assoc)
extract(key, module, {:name, get_name_and_type(module, key)}, [assoc | parents])
extract(key, module, {:name, get_name_and_type(module, key)}, [assoc | parents], config)
end

defp get_assoc_module(module, assoc) do
Expand All @@ -40,6 +49,7 @@ defmodule ExSieve.Node.Attribute do
defp get_assoc(module, key) do
:associations
|> module.__schema__()
|> Utils.filter_list(nil, not_filterable_fields(module))
|> find_field(key)
end

Expand All @@ -48,6 +58,7 @@ defmodule ExSieve.Node.Attribute do
defp get_name_and_type(module, key) do
:fields
|> module.__schema__()
|> Utils.filter_list(nil, not_filterable_fields(module))
|> find_field(key)
|> case do
nil -> nil
Expand All @@ -60,4 +71,13 @@ defmodule ExSieve.Node.Attribute do
|> Enum.sort_by(&String.length(to_string(&1)), &>=/2)
|> Enum.find(&String.starts_with?(key, to_string(&1)))
end

defp not_filterable_fields(schema) do
schema
|> function_exported?(:__ex_sieve_not_filterable_fields__, 0)
|> case do
true -> apply(schema, :__ex_sieve_not_filterable_fields__, [])
false -> nil
end
end
end

0 comments on commit 439f8b1

Please sign in to comment.