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_map = %{
"filter" => %{
"type" => "all", # all | any | none
"conditions" => [
Expand All @@ -274,12 +274,15 @@ end
}
}]
}
})
}
{:ok, filter} = Filtrex.parse(config, filter_map)

# 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.encode(filter) == filter_map
```

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
26 changes: 26 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 """
Encode filter into map
"""
@spec encode(Filtrex.t) :: Map.t
def encode(%Filtrex{type: type, conditions: conditions, sub_filters: sub_filters}) do
%{
"filter" =>
%{
"type" => type,
"conditions" => Enum.map(conditions, &Filtrex.Condition.encode_condition/1),
"sub_filters" => Enum.map(sub_filters, &encode_sub_filters/1)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because filtrex elsewhere allows for an arbitrary level of recursion for sub_filters, let's make sure this is consistent with this encoding feature being added. Checkout schema.json for the definition.

}
}
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,15 @@ defmodule Filtrex do
values = Map.get(map, key)
Map.put(map, key, values ++ [value])
end


defp encode_sub_filters(%Filtrex{type: type, conditions: conditions, sub_filters: sub_filters}) do
%{
"filter" => %{
"type" => type,
"conditions" => Enum.map(conditions, &Filtrex.Condition.encode_condition/1),
"sub_filters" => Enum.map(sub_filters, &encode_sub_filters/1)
}
}
end
end
2 changes: 1 addition & 1 deletion lib/filtrex/ast.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ defmodule Filtrex.AST do

defp build_fragments(filter) do
join = logical_join(filter.type)
Enum.map(filter.conditions, &Filtrex.Encoder.encode/1)
Enum.map(filter.conditions, &Filtrex.Encoders.Fragment.encode/1)
|> fragments(join)
|> build_sub_fragments(join, filter.sub_filters)
end
Expand Down
49 changes: 47 additions & 2 deletions lib/filtrex/condition.ex
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,29 @@ 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]

@whitelisted_encode_values ~w(column comparator value type)
@required_protocols [Filtrex.Encoders.Fragment, Filtrex.Encoders.Map]
defstruct column: nil, comparator: nil, value: nil

defmacro __using__(_) do
quote do
import Filtrex.Utils.Encoder
import Filtrex.Utils.FragmentEncoderDSL
alias Filtrex.Condition
import unquote(__MODULE__), except: [parse: 2]
@behaviour Filtrex.Condition
@after_compile {Filtrex.Condition , :ensure_protocols_implemented}

defstruct type: nil, column: nil, comparator: nil, value: nil, inverse: false
end
Expand Down Expand Up @@ -81,6 +92,23 @@ defmodule Filtrex.Condition do
if result, do: result, else: {:error, "Unknown filter key '#{key_with_comparator}'"}
end

@doc "encode condition into map"
@spec encode_condition(any_condition) :: Map.t
def encode_condition(condition) do
condition
|> put_encoded_map_value
|> Map.from_struct
|> stringify_keys
|> Map.update!("type", &Atom.to_string/1)
|> Map.take(@whitelisted_encode_values)
end

defp stringify_keys(condition) do
Enum.reduce(condition, %{}, fn ({key, value}, acc) ->
Map.put(acc, Atom.to_string(key), value)
end)
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,9 +165,26 @@ defmodule Filtrex.Condition do
Application.get_env(:filtrex, :conditions, @modules)
end

defp put_encoded_map_value(condition) do
%{condition | value: Filtrex.Encoders.Map.encode_map_value(condition)}
end

defp condition_module(type) do
Enum.find(condition_modules(), fn (module) ->
type == to_string(module.type)
end)
end

def ensure_protocols_implemented(env, _) do
Enum.each(@required_protocols, &(check_if_protocol_implemented(&1, env)))
end

defp check_if_protocol_implemented(protocol, %{module: module, file: file}) do
try do
Protocol.assert_impl!(protocol, module)
rescue
ArgumentError ->
:elixir_errors.warn 1, file, "Please implement #{inspect(protocol)} protocol"
end
end
end
6 changes: 5 additions & 1 deletion lib/filtrex/conditions/boolean.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,12 @@ defmodule Filtrex.Condition.Boolean do
defp validate_value(bool) when is_boolean(bool), do: bool
defp validate_value(_), do: nil

defimpl Filtrex.Encoder do
defimpl Filtrex.Encoders.Fragment do
encoder "equals", "does not equal", "column = ?"
encoder "does not equal", "equals", "column != ?"
end

defimpl Filtrex.Encoders.Map do
def encode_map_value(condition), do: to_string(condition.value)
end
end
6 changes: 5 additions & 1 deletion lib/filtrex/conditions/date.ex
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ defmodule Filtrex.Condition.Date do
end
end

defimpl Filtrex.Encoder do
defimpl Filtrex.Encoders.Fragment do
@format Filtrex.Validator.Date.format

encoder "after", "before", "column > ?", &default/1
Expand Down Expand Up @@ -93,4 +93,8 @@ defmodule Filtrex.Condition.Date do

defp default_value(timex_date), do: default(timex_date) |> List.first
end

defimpl Filtrex.Encoders.Map do
def encode_map_value(condition), do: Timex.format!(condition.value, "{YYYY}-{0M}-{0D}")
end
end
6 changes: 5 additions & 1 deletion lib/filtrex/conditions/datetime.ex
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ defmodule Filtrex.Condition.DateTime do
Timex.parse(value, config.options[:format] || @format)
end

defimpl Filtrex.Encoder do
defimpl Filtrex.Encoders.Fragment do
encoder "after", "before", "column > ?", &default/1
encoder "before", "after", "column < ?", &default/1

Expand All @@ -61,4 +61,8 @@ defmodule Filtrex.Condition.DateTime do
[format]
end
end

defimpl Filtrex.Encoders.Map do
def encode_map_value(condition), do: Timex.format!(condition.value, "{ISOdate} {ISOtime}")
end
end
6 changes: 5 additions & 1 deletion lib/filtrex/conditions/number.ex
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,16 @@ defmodule Filtrex.Condition.Number do

defp parse_value(_, value), do: {:error, parse_value_type_error(value, type())}

defimpl Filtrex.Encoder do
defimpl Filtrex.Encoders.Fragment do
encoder "equals", "does not equal", "column = ?"
encoder "does not equal", "equals", "column != ?"
encoder "greater than", "less than or", "column > ?"
encoder "less than or", "greater than", "column <= ?"
encoder "less than", "greater than or", "column < ?"
encoder "greater than or", "less than", "column >= ?"
end

defimpl Filtrex.Encoders.Map do
def encode_map_value(condition), do: to_string(condition.value)
end
end
6 changes: 5 additions & 1 deletion lib/filtrex/conditions/text.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,15 @@ defmodule Filtrex.Condition.Text do
end
end

defimpl Filtrex.Encoder do
defimpl Filtrex.Encoders.Fragment do
encoder "equals", "does not equal", "column = ?"
encoder "does not equal", "equals", "column != ?"

encoder "contains", "does not contain", "lower(column) LIKE lower(?)", &(["%#{&1}%"])
encoder "does not contain", "contains", "lower(column) NOT LIKE lower(?)", &(["%#{&1}%"])
end

defimpl Filtrex.Encoders.Map do
def encode_map_value(condition), do: condition.value
end
end
4 changes: 2 additions & 2 deletions lib/filtrex/encoder.ex → lib/filtrex/encoders/fragment.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
defprotocol Filtrex.Encoder do
defprotocol Filtrex.Encoders.Fragment do
@moduledoc """
Encodes a condition into `Filtrex.Fragment` as an expression with values.
Implementing this protocol is required for any new conditions.
See `Filtrex.Utils.Encoder` for helper methods with this implementation.
See `Filtrex.Utils.FragmentEncoderDSL` for helper methods with this implementation.

Example:
```
Expand Down
5 changes: 5 additions & 0 deletions lib/filtrex/encoders/map.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
defprotocol Filtrex.Encoders.Map do
@doc "The function that performs encoding on value"
@spec encode_map_value(any) :: String.t
def encode_map_value(value)
end
2 changes: 1 addition & 1 deletion lib/filtrex/fragment.ex
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule Filtrex.Fragment do
@moduledoc """
`Filtrex.Fragment` is a simple struct used to hold an `expression` and `values`.
It is used by `Filtrex.Encoder.encode/1` to turn conditions into ecto queries.
It is used by `Filtrex.Encoders.Fragment.encode/1` to turn conditions into ecto queries.
Example:
```
%Filtrex.Fragment{expression: "(text = ?)", values: ["Buy Milk"]}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
defmodule Filtrex.Utils.Encoder do
defmodule Filtrex.Utils.FragmentEncoderDSL do
@moduledoc """
Helper methods for implementing the `Filtrex.Encoder` protocol.
Helper methods for implementing the `Filtrex.Encoders.FragmentEncoder` protocol.
"""

@doc """
Expand All @@ -20,7 +20,7 @@ defmodule Filtrex.Utils.Encoder do
"""
defmacro encoder(comparator, reverse_comparator, expression, values_function \\ {:&, [], [[{:&, [], [1]}]]}) do
quote do
import Filtrex.Utils.Encoder
import Filtrex.Utils.FragmentEncoderDSL

def encode(condition = %{comparator: unquote(comparator), inverse: true}) do
condition |> struct(inverse: false, comparator: unquote(reverse_comparator)) |> encode
Expand Down
3 changes: 2 additions & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ defmodule Filtrex.Mixfile do
{:inch_ex, ">= 0.0.0", only: [:dev, :docs]},
{:plug, "~> 1.1.2", only: :test},
{:ex_machina, "~> 0.6.1", only: :test},
{:mix_test_watch, "~> 0.3", only: :dev, runtime: false}
{:mix_test_watch, "~> 0.3", only: :dev, runtime: false},
{:dialyxir, "~> 0.5", only: [:dev], runtime: false}
]
end

Expand Down
7 changes: 5 additions & 2 deletions mix.lock
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
%{"certifi": {:hex, :certifi, "0.7.0", "861a57f3808f7eb0c2d1802afeaae0fa5de813b0df0979153cbafcd853ababaf", [:rebar3], [], "hexpm"},
%{
"certifi": {:hex, :certifi, "0.7.0", "861a57f3808f7eb0c2d1802afeaae0fa5de813b0df0979153cbafcd853ababaf", [:rebar3], [], "hexpm"},
"combine": {:hex, :combine, "0.9.6", "8d1034a127d4cbf6924c8a5010d3534d958085575fa4d9b878f200d79ac78335", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"decimal": {:hex, :decimal, "1.4.1", "ad9e501edf7322f122f7fc151cce7c2a0c9ada96f2b0155b8a09a795c2029770", [:mix], [], "hexpm"},
"dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"},
"earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "2.1.2", "8d7bde4170b33e1049850a5dbe0112bfd232824e63c73ee63383df4c6ffd4064", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.12.0", "b774aabfede4af31c0301aece12371cbd25995a21bb3d71d66f5c2fe074c603f", [:mix], [{:earmark, "~> 0.2", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
Expand All @@ -21,4 +23,5 @@
"postgrex": {:hex, :postgrex, "0.13.3", "c277cfb2a9c5034d445a722494c13359e361d344ef6f25d604c2353185682bfc", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [:make, :rebar], [], "hexpm"},
"timex": {:hex, :timex, "3.1.7", "71f9c32e13ff4860e86a314303757cc02b3ead5db6e977579a2935225ce9a666", [:mix], [{:combine, "~> 0.7", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"tzdata": {:hex, :tzdata, "0.5.10", "087e8dfe8c0283473115ad8ca6974b898ecb55ca5c725427a142a79593391e90", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"}}
"tzdata": {:hex, :tzdata, "0.5.10", "087e8dfe8c0283473115ad8ca6974b898ecb55ca5c725427a142a79593391e90", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
}
14 changes: 10 additions & 4 deletions test/conditions/boolean_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,30 @@ 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 "encoding map value" do
assert Filtrex.Encoders.Map.encode_map_value(condition(false)) == "false"
assert Filtrex.Encoders.Map.encode_map_value(condition(true)) == "true"
end

test "encoding true value" do
assert Filtrex.Encoder.encode(condition(true, "equals")) ==
assert Filtrex.Encoders.Fragment.encode(condition(true, "equals")) ==
%Filtrex.Fragment{expression: "? = ?", values: [column_ref(:flag), true]}

assert Filtrex.Encoder.encode(condition(true, "does not equal")) ==
assert Filtrex.Encoders.Fragment.encode(condition(true, "does not equal")) ==
%Filtrex.Fragment{expression: "? != ?", values: [column_ref(:flag), true]}
end

test "encoding false value" do
assert Filtrex.Encoder.encode(condition(false, "equals")) ==
assert Filtrex.Encoders.Fragment.encode(condition(false, "equals")) ==
%Filtrex.Fragment{expression: "? = ?", values: [column_ref(:flag), false]}

assert Filtrex.Encoder.encode(condition(false, "does not equal")) ==
assert Filtrex.Encoders.Fragment.encode(condition(false, "does not equal")) ==
%Filtrex.Fragment{expression: "? != ?", values: [column_ref(:flag), false]}
end

Expand Down
11 changes: 10 additions & 1 deletion 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 "encoding map value" do
assert Filtrex.Encoders.Map.encode_map_value(condition("equals", ~D[2015-01-01])) == @default
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 All @@ -81,7 +85,12 @@ defmodule FiltrexConditionDateTest do

defp encode(module, column, value, comparator) do
{:ok, condition} = module.parse(@config, %{inverse: false, column: column, value: value, comparator: comparator})
encoded = Filtrex.Encoder.encode(condition)
encoded = Filtrex.Encoders.Fragment.encode(condition)
{encoded.expression, encoded.values}
end
defp condition(comparator, value) do
%Date{type: :number, column: @column,
inverse: false, comparator: comparator, value: value}
end

end
Loading