Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding UUID condition and dialyzer #68

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .formatter.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}"]
]
2 changes: 1 addition & 1 deletion config/config.exs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,6 @@ use Mix.Config

config :filtrex, ecto_repos: [Filtrex.Repo]

if Mix.env == :test do
if Mix.env() == :test do
import_config "test.exs"
end
73 changes: 49 additions & 24 deletions lib/filtrex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@ defmodule Filtrex do
defstruct type: nil, conditions: [], sub_filters: [], empty: false

@whitelist [
:filter, :type, :conditions, :sub_filters,
:column, :comparator, :value, :start, :end
:filter,
:type,
:conditions,
:sub_filters,
:column,
:comparator,
:value,
:start,
:end
]

@type t :: Filtrex.t
@type t :: Filtrex.t()

@doc """
Parses a filter expression and returns an error or the parsed filter with
Expand All @@ -35,18 +42,18 @@ defmodule Filtrex do
[%Filtrex.Type.Config{type: :text, keys: ~w(title comments)}]
```
"""
@spec parse([Filtrex.Type.Config.t], Map.t) :: {:error, String.t} | {:ok, Filtrex.t}
@spec parse([Filtrex.Type.Config.t()], map) :: {:error, String.t()} | {:ok, Filtrex.t()}
def parse(configs, map) do
with {:ok, sanitized} <- Filtrex.Params.sanitize(map, @whitelist),
{:ok, valid_structured_map} <- validate_structure(sanitized),
do: parse_validated_structure(configs, valid_structured_map)
do: parse_validated_structure(configs, valid_structured_map)
end

@doc """
Parses a filter expression, like `parse/2`. If any exception is raised when
parsing the map, a `%Filtrex{empty: true}` struct will be returned.
"""
@spec safe_parse([Filtrex.Type.Config.t], Map.t) :: Filtrex.t
@spec safe_parse([Filtrex.Type.Config.t()], map) :: Filtrex.t()
def safe_parse(configs, map) do
try do
{:ok, filter} = parse(configs, map)
Expand All @@ -65,6 +72,7 @@ defmodule Filtrex do
```
"""
def parse_params(_configs, params) when params == %{}, do: {:ok, %Filtrex{empty: true}}

def parse_params(configs, params) do
with {:ok, {type, params}} <- parse_params_filter_union(params),
{:ok, conditions} <- Filtrex.Params.parse_conditions(configs, params),
Expand All @@ -77,6 +85,7 @@ defmodule Filtrex do
will be returned.
"""
def safe_parse_params(_configs, params) when params == %{}, do: %Filtrex{empty: true}

def safe_parse_params(configs, params) do
try do
{:ok, filter} = parse_params(configs, params)
Expand All @@ -98,8 +107,9 @@ defmodule Filtrex do
Filtrex.query(query, filter, allow_empty: true)
```
"""
@spec query(Ecto.Queryable.t, Filtrex.t, Keyword.t) :: Ecto.Query.t
@spec query(Ecto.Queryable.t(), Filtrex.t(), Keyword.t()) :: Ecto.Query.t()
def query(queryable, filter, opts \\ [allow_empty: true])

def query(queryable, %Filtrex{empty: true}, opts) do
if opts[:allow_empty] do
queryable
Expand All @@ -113,6 +123,7 @@ defmodule Filtrex do
queryable
|> Filtrex.AST.build_query(filter)
|> Code.eval_quoted([], __ENV__)

result
end

Expand All @@ -123,42 +134,53 @@ defmodule Filtrex do
case map do
%{filter: %{type: type}} when type not in ~w(all any none) ->
{:error, "Invalid filter type '#{type}'"}

%{filter: %{conditions: conditions}} when conditions == [] or not is_list(conditions) ->
{:error, "One or more conditions required to filter"}

%{filter: %{sub_filters: sub_filters}} when not is_list(sub_filters) ->
{:error, "Sub-filters must be a valid list of filters"}

validated = %{filter: params} ->
sub_filters = Map.get(params, :sub_filters, [])
result = Enum.reduce_while(sub_filters, {:ok, []}, fn (sub_map, {:ok, acc}) ->
case validate_structure(sub_map) do
{:ok, sub_validated} -> {:cont, {:ok, acc ++ [sub_validated]}}
{:error, error} -> {:halt, {:error, error}}
end
end)

result =
Enum.reduce_while(sub_filters, {:ok, []}, fn sub_map, {:ok, acc} ->
case validate_structure(sub_map) do
{:ok, sub_validated} -> {:cont, {:ok, acc ++ [sub_validated]}}
{:error, error} -> {:halt, {:error, error}}
end
end)

with {:ok, validated_sub_filters} <- result,
do: {:ok, put_in(validated.filter[:sub_filters], validated_sub_filters)}
do: {:ok, put_in(validated.filter[:sub_filters], validated_sub_filters)}

_ ->
{:error, "Invalid filter structure"}
end
end

defp parse_validated_structure(configs, %{filter: params}) do
parsed_filters = Enum.reduce_while(params[:sub_filters], {:ok, []}, fn (to_parse, {:ok, acc}) ->
case parse(configs, to_parse) do
{:ok, filter} -> {:cont, {:ok, acc ++ [filter]}}
{:error, error} -> {:halt, {:error, error}}
end
end)
parsed_filters =
Enum.reduce_while(params[:sub_filters], {:ok, []}, fn to_parse, {:ok, acc} ->
case parse(configs, to_parse) do
{:ok, filter} -> {:cont, {:ok, acc ++ [filter]}}
{:error, error} -> {:halt, {:error, error}}
end
end)

with {:ok, filters} <- parsed_filters,
do: parse_conditions(configs, params[:type], params[:conditions])
|> parse_condition_results(params[:type], filters)
do:
parse_conditions(configs, params[:type], params[:conditions])
|> parse_condition_results(params[:type], filters)
end

defp parse_conditions(configs, type, conditions) do
Enum.reduce(conditions, %{errors: [], conditions: []}, fn (map, acc) ->
Enum.reduce(conditions, %{errors: [], conditions: []}, fn map, acc ->
case Filtrex.Condition.parse(configs, Map.put(map, :inverse, inverse_for(type))) do
{:error, error} ->
update_list_in_map(acc, :errors, error)

{:ok, condition} ->
update_list_in_map(acc, :conditions, condition)
end
Expand All @@ -168,6 +190,7 @@ defmodule Filtrex do
defp parse_condition_results(%{errors: [], conditions: conditions}, type, parsed_filters) do
{:ok, %Filtrex{type: type, conditions: conditions, sub_filters: parsed_filters}}
end

defp parse_condition_results(%{errors: errors}, _, _) do
{:error, Enum.join(errors, ", ")}
end
Expand All @@ -176,15 +199,17 @@ defmodule Filtrex do
case Map.fetch(params, "filter_union") do
{:ok, type} when type in ~w(all any none) ->
{:ok, {type, Map.delete(params, "filter_union")}}

:error ->
{:ok, {"all", params}}

_ ->
{:error, "Invalid filter union"}
end
end

defp inverse_for("none"), do: true
defp inverse_for(_), do: false
defp inverse_for(_), do: false

defp update_list_in_map(map, key, value) do
values = Map.get(map, key)
Expand Down
15 changes: 9 additions & 6 deletions lib/filtrex/ast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ defmodule Filtrex.AST do

defp build_fragments(filter) do
join = logical_join(filter.type)

Enum.map(filter.conditions, &Filtrex.Encoder.encode/1)
|> fragments(join)
|> build_sub_fragments(join, filter.sub_filters)
|> fragments(join)
|> build_sub_fragments(join, filter.sub_filters)
end

defp build_sub_fragments(fragments, _, []), do: fragments

defp build_sub_fragments(fragments, join, sub_filters) do
Enum.reduce(sub_filters, fragments, fn (sub_filter, [expression | values]) ->
Enum.reduce(sub_filters, fragments, fn sub_filter, [expression | values] ->
[sub_expression | sub_values] = build_fragments(sub_filter)
[join(expression, sub_expression, join) | values ++ sub_values]
end)
Expand All @@ -32,14 +34,15 @@ defmodule Filtrex.AST do

defp fragments(fragments, join) do
Enum.reduce(fragments, ["" | []], fn
(%{expression: new_expression, values: new_values}, ["" | values]) ->
%{expression: new_expression, values: new_values}, ["" | values] ->
["(#{new_expression})" | values ++ new_values]
(%{expression: new_expression, values: new_values}, [expression | values]) ->

%{expression: new_expression, values: new_values}, [expression | values] ->
combined = "#{expression} #{join} (#{new_expression})"
[combined | values ++ new_values]
end)
end

defp logical_join("any"), do: "OR"
defp logical_join(_), do: "AND"
defp logical_join(_), do: "AND"
end
67 changes: 43 additions & 24 deletions lib/filtrex/condition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,20 @@ defmodule Filtrex.Condition do
Filtrex.Condition.Date,
Filtrex.Condition.DateTime,
Filtrex.Condition.Boolean,
Filtrex.Condition.Number
Filtrex.Condition.Number,
Filtrex.Condition.UUID
]

@callback parse(Filtrex.Type.Config.t, %{inverse: boolean, column: String.t, value: any, comparator: String.t}) :: {:ok, any} | {:error, any}
@callback type :: Atom.t
@callback comparators :: [String.t]
@type t :: %__MODULE__{}

@callback parse(Filtrex.Type.Config.t(), %{
inverse: boolean,
column: String.t(),
value: any,
comparator: String.t()
}) :: {:ok, any} | {:error, any}
@callback type :: atom()
@callback comparators :: [String.t()]

defstruct column: nil, comparator: nil, value: nil

Expand Down Expand Up @@ -54,10 +62,14 @@ defmodule Filtrex.Condition do
case condition_module(type) do
nil ->
{:error, "Unknown filter condition '#{type}'"}

module ->
type_atom = String.to_existing_atom(type)
config = Filtrex.Type.Config.configs_for_type(configs, type_atom)

config =
Filtrex.Type.Config.configs_for_type(configs, type_atom)
|> Filtrex.Type.Config.config(options[:column])

if config do
module.parse(config, Map.delete(options, :type))
else
Expand All @@ -68,21 +80,24 @@ defmodule Filtrex.Condition do

@doc "Parses a params key into the condition type, column, and comparator"
def param_key_type(configs, key_with_comparator) do
result = Enum.find_value(condition_modules(), fn (module) ->
Enum.find_value(module.comparators, fn (comparator) ->
normalized = "_" <> String.replace(comparator, " ", "_")
key = String.replace_trailing(key_with_comparator, normalized, "")
config = Filtrex.Type.Config.config(configs, key)
if !is_nil(config) and key in config.keys and config.type == module.type do
{:ok, module, config, key, comparator}
end
result =
Enum.find_value(condition_modules(), fn module ->
Enum.find_value(module.comparators, fn comparator ->
normalized = "_" <> String.replace(comparator, " ", "_")
key = String.replace_trailing(key_with_comparator, normalized, "")
config = Filtrex.Type.Config.config(configs, key)

if !is_nil(config) and key in config.keys and config.type == module.type do
{:ok, module, config, key, comparator}
end
end)
end)
end)

if result, do: result, else: {:error, "Unknown filter key '#{key_with_comparator}'"}
end

@doc "Helper method to validate that a comparator is in list"
@spec validate_comparator(atom, binary, List.t) :: {:ok, binary} | {:error, binary}
@spec validate_comparator(atom, binary, list) :: {:ok, binary} | {:error, binary}
def validate_comparator(type, comparator, comparators) do
if comparator in comparators do
{:ok, comparator}
Expand All @@ -92,9 +107,10 @@ defmodule Filtrex.Condition do
end

@doc "Helper method to validate whether a value is in a list"
@spec validate_in(any, List.t) :: nil | any
@spec validate_in(any, list) :: nil | any
def validate_in(nil, _), do: nil
def validate_in(_, nil), do: nil

def validate_in(value, list) do
cond do
value in list -> value
Expand All @@ -103,32 +119,35 @@ defmodule Filtrex.Condition do
end

@doc "Helper method to validate whether a value is a binary"
@spec validate_is_binary(any) :: nil | String.t
@spec validate_is_binary(any) :: nil | String.t()
def validate_is_binary(value) when is_binary(value), do: value
def validate_is_binary(_), do: nil

@doc "Generates an error description for a generic parse error"
@spec parse_error(any, Atom.t, Atom.t) :: String.t
@spec parse_error(any, atom, atom) :: String.t()
def parse_error(value, type, filter_type) do
"Invalid #{to_string(filter_type)} #{to_string(type)} '#{value}'"
end

@doc "Generates an error description for a parse error resulting from an invalid value type"
@spec parse_value_type_error(any, Atom.t) :: String.t
@spec parse_value_type_error(any, atom) :: String.t()
def parse_value_type_error(column, filter_type) when is_binary(column) do
"Invalid #{to_string(filter_type)} value for #{column}"
end

def parse_value_type_error(column, filter_type) do
opts = struct(Inspect.Opts, [])
iodata = Inspect.Algebra.to_doc(column, opts)
opts = struct(Inspect.Opts, [])

iodata =
Inspect.Algebra.to_doc(column, opts)
|> Inspect.Algebra.format(opts.width)
|> Enum.join
|> Enum.join()

if String.length(iodata) <= 15 do
parse_value_type_error("'#{iodata}'", filter_type)
else
"'#{String.slice(iodata, 0..12)}...#{String.slice(iodata, -3..-1)}'"
|> parse_value_type_error(filter_type)
|> parse_value_type_error(filter_type)
end
end

Expand All @@ -138,7 +157,7 @@ defmodule Filtrex.Condition do
end

defp condition_module(type) do
Enum.find(condition_modules(), fn (module) ->
Enum.find(condition_modules(), fn module ->
type == to_string(module.type)
end)
end
Expand Down
6 changes: 4 additions & 2 deletions lib/filtrex/conditions/boolean.ex
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ defmodule Filtrex.Condition.Boolean do
case condition do
%Condition.Boolean{comparator: nil} ->
{:error, parse_error(comparator, :comparator, :date)}

%Condition.Boolean{value: nil} ->
{:error, parse_value_type_error(value, :boolean)}

_ ->
{:ok, condition}
end
Expand All @@ -38,7 +40,7 @@ defmodule Filtrex.Condition.Boolean do
defp validate_value(_), do: nil

defimpl Filtrex.Encoder do
encoder "equals", "does not equal", "column = ?"
encoder "does not equal", "equals", "column != ?"
encoder("equals", "does not equal", "column = ?")
encoder("does not equal", "equals", "column != ?")
end
end
Loading