Skip to content

Commit

Permalink
Adding UUID condition
Browse files Browse the repository at this point in the history
  • Loading branch information
fabio-t committed Mar 3, 2023
1 parent 392a87a commit 1572704
Show file tree
Hide file tree
Showing 34 changed files with 879 additions and 465 deletions.
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

0 comments on commit 1572704

Please sign in to comment.