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

Create dump/1 for saving filters. #57

Open
wants to merge 12 commits into
base: master
Choose a base branch
from
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ With this example, a query such as this one would filter comments:

# Storing Filters

In addition to parsing parameters, filtrex also enables parsing from a map syntax that is easily encodable to and from JSON. This feature allows storing a filter for future use (e.g. routinely checking for comments that mention "ElixirConf" or todos that are not completed).
In addition to parsing parameters, filtrex also enables parsing from a map syntax or dumping into it. Maps is easily encodable to and from JSON. This feature allows storing a filter for future use (e.g. routinely checking for comments that mention "ElixirConf" or todos that are not completed).

```elixir
# create validation options for keys and formats
Expand All @@ -257,7 +257,7 @@ config = defconfig do
end

# parse a filter from map syntax
{:ok, filter} = Filtrex.parse(config, %{
filter_dump = %{
rcdilorenzo marked this conversation as resolved.
Show resolved Hide resolved
"filter" => %{
"type" => "all", # all | any | none
"conditions" => [
Expand All @@ -274,12 +274,15 @@ end
}
}]
}
})
}
{:ok, filter} = Filtrex.parse(config, filter_dump)

# Encode filter structure into where clause on Ecto query
query = from(m in MyApp.Todo, where: m.rating > 90)
|> Filtrex.query(filter) # => #Ecto.Query<...

# Dumping filter
Filtrex.dump(filter) == filter_dump
rcdilorenzo marked this conversation as resolved.
Show resolved Hide resolved
```

For more details on the acceptable structure of this map, feel free to take a look at the [example json schema](http://jeremydorn.com/json-editor/?schema=N4IgJgpgZglgdjALjA9nAziAXKAYjAG0QgCdtRlECJsR8jSQAaERATwAcasQUAjAFYQAxomYgOJFFxLIImHCFgMyi9l1r8ho8ZOmk5Cip27GNPdIhLwA5uIhwArgFtsAbRABDAgXGe4bOJwaDQAugC+LMJoYEioGOSsJrSeJCSegSxIEM5GSea8giJiLHoyhonRBC5wiercIJbWcHaRINHOHKmeiCiqZg1NtiBt9XXJFlbDLA4u7iB8KCjU/uLEAB4l4D00LGA7yM67IE7OfIwRLABu3o6m+YNTLSPhryzojnwA+srEJHljHipdKZEAkCAAR0cMHBYGwUG86AgWWIuUSABJwVBaABiAD0kFgCGQaHQePofxer2pLEx0FxBOg8DipPJhEp4SAAA==&value=N4IgZglgNgLgpgJxALlDAngBzikBDKKEAGhAGMB7AOwBMIYJqBnFAbVEqgFcBbK3BjCg5SlHpjwI8MCkmTlqMPBCotSGbALgAPGCRAA3AlxzyAQl3QgAvsQ4VufAfWH6xEqTLkgaFOEwACKgoYAMoqJRV9DVMQeF19I25YgFloAGsbOwVHfnkwKDwAczcKcUlpWVw4AEcuAjU4rFiAIwoHODx+UiSTXDAGnGsAXVImLhaAfUhYRBZkdnBoeDk0Ztwuq1FqOgZmNntc3BoTSZppEQVyzyr5WvqoRpjji8TjWIAmAAYARgA2AC0XwAzACPn8bMNrCNoUAAAA==&theme=bootstrap2&iconlib=fontawesome4&object_layout=grid&show_errors=interaction) or the raw [JSON schema config](resources/schema.json).
Expand Down
25 changes: 25 additions & 0 deletions lib/filtrex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,21 @@ defmodule Filtrex do
do: parse_validated_structure(configs, valid_structured_map)
end

@doc """
Dumps filter into map
"""
@spec dump(Filtrex.t) :: Map.t
def dump(%Filtrex{type: type, conditions: conditions, sub_filters: sub_filters}) do
%{
"filter" =>
%{
"type" => type,
"conditions" => Enum.map(conditions, &Filtrex.Condition.dump/1),
"sub_filters" => Enum.map(sub_filters, &dump_sub_filters/1)
}
}
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.
Expand Down Expand Up @@ -190,4 +205,14 @@ defmodule Filtrex do
values = Map.get(map, key)
Map.put(map, key, values ++ [value])
end


defp dump_sub_filters(%Filtrex{type: type, conditions: conditions}) do
%{
"filter" => %{
"type" => type,
"conditions" => Enum.map(conditions, &Filtrex.Condition.dump/1),
}
}
end
end
28 changes: 27 additions & 1 deletion lib/filtrex/condition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,19 @@ defmodule Filtrex.Condition do
Filtrex.Condition.Number
]

@type any_condition ::
Filtrex.Condition.Text.t |
Filtrex.Condition.Date.t |
Filtrex.Condition.DateTime.t |
Filtrex.Condition.Boolean.t |
Filtrex.Condition.Number.t


@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]

@callback dump_value(any) :: String
@whitelisted_dump_values ~w(column comparator value type)
defstruct column: nil, comparator: nil, value: nil

defmacro __using__(_) do
Expand Down Expand Up @@ -81,6 +90,19 @@ defmodule Filtrex.Condition do
if result, do: result, else: {:error, "Unknown filter key '#{key_with_comparator}'"}
end

@doc "Dumps condition into map"
@spec dump(any_condition) :: Map.t
def dump(condition) do
condition
|> put_dump_value
|> Map.from_struct
|> Enum.reduce(%{}, fn ({key, value}, acc) ->
rcdilorenzo marked this conversation as resolved.
Show resolved Hide resolved
Map.put(acc, Atom.to_string(key), value)
end)
|> Map.update!("type", &(Atom.to_string(&1)))
rcdilorenzo marked this conversation as resolved.
Show resolved Hide resolved
|> Map.take(@whitelisted_dump_values)
end

@doc "Helper method to validate that a comparator is in list"
@spec validate_comparator(atom, binary, List.t) :: {:ok, binary} | {:error, binary}
def validate_comparator(type, comparator, comparators) do
Expand Down Expand Up @@ -137,6 +159,10 @@ defmodule Filtrex.Condition do
Application.get_env(:filtrex, :conditions, @modules)
end

defp put_dump_value(condition) do
Map.update!(condition, :value, &(condition.__struct__.dump_value(&1)))
rcdilorenzo marked this conversation as resolved.
Show resolved Hide resolved
end

defp condition_module(type) do
Enum.find(condition_modules(), fn (module) ->
type == to_string(module.type)
Expand Down
2 changes: 2 additions & 0 deletions lib/filtrex/conditions/boolean.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ defmodule Filtrex.Condition.Boolean do

def comparators, do: ["equals", "does not equal"]

def dump_value(value), do: "#{value}"

def parse(_config, %{column: column, comparator: comparator, value: value, inverse: inverse}) do
parsed_comparator = validate_in(comparator, comparators())

Expand Down
2 changes: 2 additions & 0 deletions lib/filtrex/conditions/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ defmodule Filtrex.Condition.Date do

def comparators, do: @comparators

def dump_value(value), do: Timex.format!(value, "{YYYY}-{0M}-{0D}")
rcdilorenzo marked this conversation as resolved.
Show resolved Hide resolved

def parse(config, %{column: column, comparator: comparator, value: value, inverse: inverse}) do
with {:ok, parsed_comparator} <- validate_comparator(comparator),
{:ok, parsed_value} <- validate_value(config, parsed_comparator, value) do
Expand Down
2 changes: 2 additions & 0 deletions lib/filtrex/conditions/datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ defmodule Filtrex.Condition.DateTime do

def comparators, do: @comparators

def dump_value(value), do: Timex.format!(value, "{ISOdate} {ISOtime}")

def parse(config, %{column: column, comparator: comparator, value: value, inverse: inverse}) do
with {:ok, parsed_comparator} <- validate_comparator(type(), comparator, @comparators),
{:ok, parsed_value} <- validate_value(config, value) do
Expand Down
2 changes: 2 additions & 0 deletions lib/filtrex/conditions/number.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ defmodule Filtrex.Condition.Number do
"greater than or", "less than"
]

def dump_value(value), do: "#{value}"

def parse(config, %{column: column, comparator: comparator, value: value, inverse: inverse}) do
result = with {:ok, parsed_value} <- parse_value(config.options, value),
do: %Condition.Number{type: type(), inverse: inverse, value: parsed_value, column: column,
Expand Down
1 change: 1 addition & 0 deletions lib/filtrex/conditions/text.ex
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ defmodule Filtrex.Condition.Text do

def comparators, do: @comparators

def dump_value(value), do: value
@doc """
Tries to create a valid text condition struct, calling helper methods
from `Filtrex.Condition` to validate each type. If any of the types are not valid,
Expand Down
6 changes: 6 additions & 0 deletions test/conditions/boolean_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,17 @@ defmodule FiltrexConditionBooleanTest do
{:ok, condition(false)}
end


test "throwing error for non-boolean value" do
assert Boolean.parse(@config, params("blah")) ==
{:error, "Invalid boolean value for blah"}
end

test "dumping boolean values" do
assert Boolean.dump_value(false) == "false"
assert Boolean.dump_value(true) == "true"
end

test "encoding true value" do
assert Filtrex.Encoder.encode(condition(true, "equals")) ==
%Filtrex.Fragment{expression: "? = ?", values: [column_ref(:flag), true]}
Expand Down
4 changes: 4 additions & 0 deletions test/conditions/date_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ defmodule FiltrexConditionDateTest do
}) |> elem(0) == :ok
end

test "dumping date value" do
assert Date.dump_value(~D[2018-11-11]) == "2018-11-11"
end

test "encoding as SQL fragments for ecto" do
assert encode(Date, @column, @default, "after") == {"? > ?", [column_ref(:date_column), @default]}
assert encode(Date, @column, @default, "on or after") == {"? >= ?", [column_ref(:date_column), @default]}
Expand Down
4 changes: 4 additions & 0 deletions test/conditions/datetime_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ defmodule FiltrexConditionDateTimeTest do
}) |> elem(0) == :error
end

test "dumping datetime value" do
assert DateTime.dump_value(Timex.parse!(@default, "{ISO:Extended}")) == @default_converted
end

test "encoding as SQL fragments for ecto" do
assert encode(DateTime, @column, @default, "after") == {"? > ?", [column_ref(:datetime_column), @default_converted]}
assert encode(DateTime, @column, @default, "on or after") == {"? >= ?", [column_ref(:datetime_column), @default_converted]}
Expand Down
4 changes: 4 additions & 0 deletions test/conditions/number_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ defmodule FiltrexConditionNumberTest do
{:error, "Invalid number value for 10.5"}
end

test "dumping numbers" do
assert Number.dump_value(123) == "123"
end

test "validating range of allowed integer values" do
assert Number.parse(@config, params("equals", "101")) ==
{:error, "Provided number value not allowed"}
Expand Down
5 changes: 5 additions & 0 deletions test/conditions/text_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ defmodule FiltrexConditionTextTest do
})
end

test "dumping text" do
text = "Lorem Ipsum"
assert Text.dump_value(text) == text
end

test "encoding as SQL fragments for ecto" do
{:ok, condition} = Text.parse(@config, %{inverse: false, column: "title", value: "Buy Milk", comparator: "equals"})
encoded = Filtrex.Encoder.encode(condition)
Expand Down
30 changes: 30 additions & 0 deletions test/filtrex_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,36 @@ defmodule FiltrexTest do
assert {:error, "Unknown key 'types'"} == Filtrex.parse(@config, invalid_map)
end

test "dumping" do
filters_dump = %{
"filter" =>
%{
"type" => "all",
"conditions" => [
%{"column" => "title", "comparator" => "contains", "value" => "Buy", "type" => "text"},
],
"sub_filters" => [
%{
"filter" =>
%{
"type" => "any",
"conditions" => [
%{
"column" => "date_column",
"comparator" => "equals",
"value" => "2016-03-26",
"type" => "date"
}
]
}
}
]
}
}
{:ok, filter} = Filtrex.parse(@config, filters_dump)
assert Filtrex.dump(filter) == filters_dump
end

test "pipelining to query" do
query_string = "title_contains=earth"
params = Plug.Conn.Query.decode(query_string)
Expand Down